agent-sanitizer 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,976 @@
1
+ /**
2
+ * Invisible-character + ANSI/SGR primitives with no external runtime deps.
3
+ *
4
+ * Removes payload-capable Unicode (general-category Cf format chars, variation
5
+ * selectors, blank-rendering fillers, soft hyphens, interior BOMs) while
6
+ * preserving ZWNJ/ZWJ in genuine linguistic and emoji contexts, and reports
7
+ * which categories were removed. The linguistic carve-out is driven by the
8
+ * generated Unicode Joining_Type / virama tables in ./joining-type.mjs (a
9
+ * sibling data module, not a package), so it decides preservation from the
10
+ * actual cursive-join semantics rather than a hand-rolled script guess.
11
+ */
12
+ import { joiningType, isVirama } from "./joining-type.mjs";
13
+ import { isStandardizedVariant } from "./standardized-variants.mjs";
14
+ import { CF_CODEPOINTS } from "./cf-charset.mjs";
15
+
16
+ export const VS = [
17
+ ...Array.from({ length: 16 }, (_, i) => 0xfe00 + i),
18
+ ...Array.from({ length: 240 }, (_, i) => 0xe0100 + i),
19
+ ]
20
+ .map((codePoint) => String.fromCodePoint(codePoint))
21
+ .join("");
22
+
23
+ // Combining marks (general category Mn) that carry NO advance width \u2014 they
24
+ // render as nothing on their own, so a run of them is a hidden channel exactly
25
+ // like a zero-width Cf char, yet \p{Cf} misses them (they are Mn). Strictly
26
+ // enumerated, one reason per entry, because MOST Mn marks DO have visible width
27
+ // (accents, vowel signs) and must never be stripped \u2014 only these zero-advance
28
+ // ones qualify. Driven as an SSOT so the test iterates one case per member.
29
+ // U+034F COMBINING GRAPHEME JOINER \u2014 invisible; affects only collation/shaping
30
+ // U+17B4 KHMER VOWEL INHERENT AQ \u2014 zero-width inherent vowel, renders blank
31
+ // U+17B5 KHMER VOWEL INHERENT AA \u2014 zero-width inherent vowel, renders blank
32
+ export const ZERO_WIDTH_MN = "\u034F\u17B4\u17B5";
33
+
34
+ // Code points that render blank / zero-width but are NOT general category Cf,
35
+ // so the \p{Cf} check below misses them: the Hangul fillers (category Lo,
36
+ // U+115F/U+1160/U+3164/U+FFA0), the Braille blank pattern (category So,
37
+ // U+2800), and the zero-width combining marks above (category Mn). A run of any
38
+ // of these carries a hidden payload exactly as zero-widths do.
39
+ export const BLANK_NON_CF = "\u115F\u1160\u3164\uFFA0\u2800" + ZERO_WIDTH_MN;
40
+
41
+ const REGEX_FLAGS = "gu";
42
+
43
+ // A regex character-class source (`[…]`, `u`-flag) matching EXACTLY the pinned
44
+ // Cf code points in CF_CODEPOINTS — the version-locked stand-in for a live
45
+ // `\p{Cf}` test. Node (U17) and the CPython interpreter the Python port runs on
46
+ // (often U14/U15) disagree on `\p{Cf}`, so testing `\p{Cf}` live here would strip
47
+ // a DIFFERENT set than the port and let a key spliced with a code point in the
48
+ // version delta (e.g. U+13439) escape the older-Unicode layer. Reading the pinned
49
+ // set (generated by scripts/gen-invisible-charset.mjs, the same list the port
50
+ // reads from data/invisible-charset.json) closes that cross-layer gap. Consecutive
51
+ // code points collapse to `\u{a}-\u{b}` ranges so the class stays compact.
52
+ /** @param {readonly number[]} codepoints @returns {string} */
53
+ function cfClassSource(codepoints) {
54
+ const sorted = [...codepoints].sort((a, b) => a - b);
55
+ let src = "[";
56
+ for (let i = 0; i < sorted.length;) {
57
+ let j = i;
58
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++;
59
+ src += `\\u{${sorted[i].toString(16)}}`;
60
+ if (j > i) src += `-\\u{${sorted[j].toString(16)}}`;
61
+ i = j + 1;
62
+ }
63
+ return src + "]";
64
+ }
65
+
66
+ const CF_CLASS_SOURCE = cfClassSource(CF_CODEPOINTS);
67
+
68
+ // Stable, machine-readable category codes for the `found` array returned by
69
+ // stripInvisibleWithReport and sanitize. These are API: branch on them. They
70
+ // are deliberately NOT the human-facing prose (that lives in `warnings` and in
71
+ // CATEGORY_LABELS), so the display wording can be reworded without a breaking
72
+ // change to anyone matching on `found`.
73
+ export const CATEGORY = Object.freeze({
74
+ CF: "cf-format",
75
+ VARIATION_SELECTORS: "variation-selectors",
76
+ BLANK_FILLERS: "blank-fillers",
77
+ ANSI: "ansi",
78
+ LONE_SURROGATES: "lone-surrogates",
79
+ HTML_COMMENTS: "html-comments",
80
+ HIDDEN_HTML: "hidden-html",
81
+ EXFIL_URLS: "exfil-urls",
82
+ });
83
+
84
+ // code -> human label, used only to build `warnings` text. Decoupled from
85
+ // CATEGORY so prose changes never alter the machine-readable `found` contract.
86
+ /** @type {Readonly<Record<string, string>>} */
87
+ export const CATEGORY_LABELS = Object.freeze({
88
+ [CATEGORY.CF]: "Format chars (Cf)",
89
+ [CATEGORY.VARIATION_SELECTORS]: "Variation selectors",
90
+ [CATEGORY.BLANK_FILLERS]: "Blank-rendering fillers",
91
+ [CATEGORY.ANSI]: "ANSI escapes",
92
+ [CATEGORY.LONE_SURROGATES]: "Lone UTF-16 surrogates",
93
+ [CATEGORY.HTML_COMMENTS]: "HTML comments",
94
+ [CATEGORY.HIDDEN_HTML]: "hidden HTML",
95
+ [CATEGORY.EXFIL_URLS]: "exfil URLs",
96
+ });
97
+
98
+ /** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
99
+ export const CHECKS = [
100
+ [CATEGORY.CF, new RegExp(CF_CLASS_SOURCE, REGEX_FLAGS)],
101
+ [CATEGORY.VARIATION_SELECTORS, new RegExp(`[${VS}]`, REGEX_FLAGS)],
102
+ // BLANK_NON_CF includes zero-width COMBINING marks (Mn: U+034F/17B4/17B5). In
103
+ // a `u`-flag class each matches its own single code point — exactly the intent
104
+ // (we strip the lone mark, never a base+mark grapheme), so the
105
+ // misleading-character-class heuristic is a false positive here.
106
+ // eslint-disable-next-line no-misleading-character-class -- single-code-point matches under the u flag are intentional
107
+ [CATEGORY.BLANK_FILLERS, new RegExp(`[${BLANK_NON_CF}]`, REGEX_FLAGS)],
108
+ ];
109
+
110
+ export const STRIP = new RegExp(
111
+ CHECKS.map(([, regex]) => regex.source).join("|"),
112
+ REGEX_FLAGS,
113
+ );
114
+
115
+ // SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
116
+ // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
117
+ // text, never reposition the cursor, erase, or smuggle an OSC string. `:` is
118
+ // included alongside `;` because ITU T.416 colon-separated SGR sub-parameters
119
+ // (truecolor `ESC[38:2:255:0:0m`, as emitted by tmux/kitty/mintty) are pure
120
+ // display-only SGR too — excluding them left a benign colon-form sequence
121
+ // misread as non-SGR. A SGR sequence has TWO encodings: the 7-bit `ESC [ … m`
122
+ // and the 8-bit C1 form where a single U+009B (CSI) replaces `ESC [` — spelled
123
+ // here as the `\x9b` escape (never a raw literal byte in source: an
124
+ // undetectable-by-eye invisible byte in a regex literal is a correctness
125
+ // landmine for the next person who touches this line without a hex dump).
126
+ // Both encodings must be recognized — otherwise a C1-introduced
127
+ // `U+009B 31m … 0m` is pure color yet is misread as a non-SGR payload (or,
128
+ // worse, mistaken for SGR-only when its introducer was a C1 CSI that
129
+ // isSgrOnly's ESC-only test never saw). Text is "SGR-only" when removing
130
+ // these leaves no ANSI control introducer at all — a lone or partial escape is
131
+ // therefore not SGR-only.
132
+ // eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
133
+ export const SGR_RE = /(?:\x1b\[|\x9b)[0-9;:]*m/g;
134
+
135
+ // The raw ANSI control introducers isSgrOnly must treat as NON-SGR after SGR
136
+ // removal: 7-bit ESC (U+001B) and the entire 8-bit C1 control block
137
+ // (U+0080–U+009F) — CSI (U+009B), the DCS/SOS/OSC/PM/APC string introducers, and
138
+ // ST. isSgrOnly is honest only if it tests for ALL of them — a C1 cursor-move or
139
+ // erase (`U+009B 2J`) leaves a U+009B, a C1-OSC string (`U+009D … BEL`) leaves a
140
+ // U+009D, and a C1-DCS/APC payload (`U+0090 … ST`) leaves its introducer, after
141
+ // SGR removal; each must read as NOT SGR-only, exactly as their 7-bit `ESC[2J` /
142
+ // `ESC]…` / `ESC P…` twins do. Omitting any would let a residual C1 introducer
143
+ // be misread as SGR-only.
144
+ // eslint-disable-next-line no-control-regex -- the raw introducers are what we test for
145
+ const CONTROL_INTRODUCER_RE = /[\x1b\u0080-\u009f]/;
146
+
147
+ /**
148
+ * True when every ANSI control introducer in `text` belongs to a display-only
149
+ * SGR color sequence (so stripping the ANSI removed only cosmetic styling,
150
+ * nothing that could move the cursor, erase, or carry a payload). Recognizes
151
+ * both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
152
+ * @param {string} text
153
+ * @returns {boolean}
154
+ */
155
+ export function isSgrOnly(text) {
156
+ return !CONTROL_INTRODUCER_RE.test(text.replace(SGR_RE, ""));
157
+ }
158
+
159
+ export const LONG_RUN_THRESHOLD = 10;
160
+
161
+ /** Total invisible-char count above which a file/prompt is treated as
162
+ * payload-capable even without a long run (threshold-evasion catch). */
163
+ export const SCATTERED_THRESHOLD = 30;
164
+
165
+ export const LONG_RUN_RE = new RegExp(
166
+ `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},}`,
167
+ REGEX_FLAGS,
168
+ );
169
+
170
+ /**
171
+ * The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
172
+ * labels, the LONG RUN marker when the de-ANSI'd text still holds a
173
+ * payload-length invisible run, and a pointer to recover the bytes — a hex dump
174
+ * is ASCII, so it passes through sanitization untouched. The single source of
175
+ * this note, shared by the `sanitize` convenience entry and the tool-output
176
+ * pipeline so the two can't drift.
177
+ * @param {string[]} invisFound CATEGORY codes applyLayer1 reported removing
178
+ * @param {string} deAnsi ANSI-stripped text (invisible runs intact), for the LONG_RUN probe
179
+ * @returns {string}
180
+ */
181
+ export function describeStripped(invisFound, deAnsi) {
182
+ let msg = `Stripped: ${invisFound.map((code) => CATEGORY_LABELS[code]).join(", ")}`;
183
+ LONG_RUN_RE.lastIndex = 0;
184
+ // Probe only the PAYLOAD invisibles: a legitimate emoji/flag/variation
185
+ // sequence is carve-out-preserved and masked out here, so it never trips the
186
+ // injection marker (alert fatigue) while a genuine hidden run still surfaces.
187
+ if (LONG_RUN_RE.test(payloadInvisibleView(deAnsi)))
188
+ msg += " [LONG RUN — possible injection payload]";
189
+ return (
190
+ msg +
191
+ " — inspect the removed bytes with a hex dump (xxd / od -c), which survives sanitization"
192
+ );
193
+ }
194
+
195
+ // Leading-BOM marker, preserved by stripInvisibleWithReport (see its doc).
196
+ const BOM = "\uFEFF";
197
+ // ─── ZWNJ/ZWJ linguistic carve-out ───────────────────────────────────────────
198
+ // ZWNJ (U+200C) and ZWJ (U+200D) are general category Cf, so the STRIP pass
199
+ // would treat them as hidden-payload bytes. But they are MANDATORY for correct
200
+ // rendering between letters of several scripts (Arabic/Persian and many Indic
201
+ // scripts) and inside emoji ZWJ sequences — blanket stripping corrupts
202
+ // legitimate non-English output. Preserve them only where they do real
203
+ // rendering work, decided SYNTACTICALLY from the neighbours' Unicode
204
+ // Joining_Type (see isPreservedJoiner): a joiner between two cursive letters, an
205
+ // Indic joiner after a virama, an emoji joiner between emoji components. Strip
206
+ // them as payload everywhere else — leading/trailing, next to a non-joining
207
+ // character (ASCII, punctuation, a non-connecting letter), or inside a joiner
208
+ // run. Over-strip beats under-strip only on genuine ambiguity; a joiner sitting
209
+ // between two real cursive letters is treated as content and kept.
210
+ const ZWNJ = 0x200c;
211
+ const ZWJ = 0x200d;
212
+
213
+ // Max joiners the carve-out will PRESERVE within one uninterrupted joined
214
+ // cluster (a `letter (joiner letter)*` chain, or an emoji ZWJ sequence). A real
215
+ // word or emoji glyph needs only a handful in a row — the longest standard emoji
216
+ // ZWJ sequences (family-of-four, profession+ZWJ) carry three; a Persian
217
+ // compound a couple. Past this many consecutive PRESERVED joiners with no real
218
+ // gap between them, the chain is treated as a zero-width payload channel (an
219
+ // attacker alternates `letter joiner letter joiner …` so every joiner still sits
220
+ // between two cursive letters) and the surplus joiners are stripped. The counter
221
+ // resets at any genuine gap — two visible characters in a row, i.e. text that is
222
+ // NOT part of a joined cluster — so an ordinary single joiner per word is kept.
223
+ export const CONSECUTIVE_JOINER_CAP = 8;
224
+
225
+ // Max variation selectors the carve-out preserves in one uninterrupted run of
226
+ // selected glyphs (an `ideograph selector ideograph selector …` chain, or the
227
+ // standardized-variation equivalent). Each selector encodes up to a byte, so an
228
+ // attacker who variant-selects every character of a CJK/base run opens a
229
+ // high-bit-rate channel that the joiner cap does not touch (these are not
230
+ // joiners). A genuine document variant-selects only the odd rare glyph, never a
231
+ // long unbroken run, so past this many consecutive PRESERVED IVS/stdvs with no
232
+ // real gap the surplus is stripped. The counter resets at a genuine gap (two
233
+ // visible characters in a row), exactly like CONSECUTIVE_JOINER_CAP.
234
+ export const CONSECUTIVE_SELECTOR_CAP = 8;
235
+
236
+ // Floor on the document-wide preserve budget, shared by both preserve kinds
237
+ // (see `kind` in analyzeCarve: joiners AND presentation selectors draw from
238
+ // the same counter). The Joining_Type gate strips joiners that do no
239
+ // rendering work regardless of count, so the bulk covert channel (ZWNJ
240
+ // scattered through Latin/ASCII/mixed text) is closed by shape, not by
241
+ // counting. What remains is the residual channel of MEANINGFUL joiners/
242
+ // selectors stuffed into genuine cover text — each individually legitimate,
243
+ // so indistinguishable one at a time. THIS budget (with CONSECUTIVE_JOINER_CAP
244
+ // and SCATTERED_THRESHOLD) is the explicit, tunable bound on that residual
245
+ // channel; it is not derived, and tightening it trades covert-channel width for
246
+ // clipping genuinely dense prose or emoji-dense text. The allowance is
247
+ // proportional to visible length (PRESERVED_JOINER_PER_VISIBLE), never below
248
+ // this floor; the floor keeps short but joiner/selector-dense strings (a lone
249
+ // emoji ZWJ sequence, a two-word Persian phrase) un-flagged. Past the
250
+ // allowance the surplus is stripped AND reported.
251
+ export const TOTAL_PRESERVED_JOINER_BUDGET = 16;
252
+
253
+ // Visible code points required per additional preserved joiner above the floor.
254
+ // Measured formal/literary Persian runs ~1 ZWNJ per 5 words (~25 visible chars);
255
+ // 1-per-8 sits comfortably above real prose density while still bounding the
256
+ // residual channel to a fixed fraction of the cover text.
257
+ export const PRESERVED_JOINER_PER_VISIBLE = 8;
258
+
259
+ // Absolute ceiling on the document-wide preserve allowance, regardless of how
260
+ // much visible cover text an attacker supplies. Without it, maxPreserved grows
261
+ // linearly with visible length (visibleLen / PRESERVED_JOINER_PER_VISIBLE), so a
262
+ // large benign-looking body lets the residual joiner/selector channel scale with
263
+ // attacker input — preserved chars do not count toward the scatter floor, so
264
+ // nothing else bounds it. This caps the whole channel at a fixed width no cover
265
+ // text can widen.
266
+ export const PRESERVE_HARD_CAP = 64;
267
+
268
+ // Scripts whose orthography uses ZWNJ/ZWJ between letters as a rendering
269
+ // control. The runtime gate is now script-agnostic (it reads Joining_Type, so it
270
+ // covers every cursive/Brahmic script, not just these), but this list remains
271
+ // the public, TESTED SSOT of the scripts the carve-out is designed for: the
272
+ // suite drives one preserve-case per entry, so a regression in any of them
273
+ // fails.
274
+ export const LINGUISTIC_SCRIPTS = [
275
+ "Arabic",
276
+ "Devanagari",
277
+ "Bengali",
278
+ "Gurmukhi",
279
+ "Gujarati",
280
+ "Oriya",
281
+ "Tamil",
282
+ "Telugu",
283
+ "Kannada",
284
+ "Malayalam",
285
+ "Sinhala",
286
+ ];
287
+ // Left side of an emoji joiner: a pictograph or a skin-tone modifier (a base
288
+ // emoji may carry a modifier before the joiner, e.g. a health-worker sequence).
289
+ const EMOJI_LEFT = /[\p{Extended_Pictographic}\p{Emoji_Modifier}]/u;
290
+ // The three keycap bases (digit, `#`, `*`) Unicode's keycap grammar allows
291
+ // before VS16 + U+20E3 COMBINING ENCLOSING KEYCAP (1️⃣ #️⃣ *️⃣ … 9️⃣). None is
292
+ // Extended_Pictographic or Emoji_Modifier, so EMOJI_LEFT misses them; the
293
+ // presentation-selector check accepts one ONLY when the required U+20E3 keycap
294
+ // actually follows the selector (see isEmojiPresentationSelector). A bare
295
+ // digit/`#`/`*` + VS with no keycap is NOT a glyph — it is a hidden presentation
296
+ // selector spliced after ordinary text, the top low-effort VS-smuggling shape —
297
+ // so it fails closed and is stripped. The base set is confined to the keycap
298
+ // check (never folded into EMOJI_LEFT) so it cannot loosen the ZWJ carve-out.
299
+ const KEYCAP_BASE = /[0-9#*]/u;
300
+ // U+20E3 COMBINING ENCLOSING KEYCAP — the mandatory terminator of a keycap
301
+ // sequence, the one signal that a digit/`#`/`*` + VS16 is a real glyph.
302
+ const COMBINING_KEYCAP = 0x20e3;
303
+ // Right side of an emoji joiner is always the next component's base pictograph.
304
+ const EMOJI_BASE = /\p{Extended_Pictographic}/u;
305
+ // A variation selector legitimately sits between a base pictograph and a
306
+ // following ZWJ (🏳️‍🌈 = flag base, VS16, ZWJ, rainbow; 👁️‍🗨️), so the joiner's real
307
+ // left neighbor for the emoji test is the pictograph, not the selector.
308
+ const VARIATION_SELECTOR = new RegExp(`[${VS}]`, "u");
309
+ // U+FE0F (VS16) forces emoji presentation, U+FE0E (VS15) forces text
310
+ // presentation (☺︎ vs ☺); either one directly after a pictograph is part of a
311
+ // visible glyph, not a hidden variation-selector run.
312
+ const PRESENTATION_SELECTORS = new Set([0xfe0e, 0xfe0f]);
313
+
314
+ // ─── Emoji tag sequences (subregional flags) ─────────────────────────────────
315
+ // A subregional flag (🏴󠁧󠁢󠁳󠁣󠁴󠁿 Scotland, 🏴󠁧󠁢󠁷󠁬󠁳󠁿 Wales, 🏴󠁧󠁢󠁥󠁮󠁧󠁿 England …) is a WAVING
316
+ // BLACK FLAG tag_base (U+1F3F4, a visible pictograph) followed by one or more
317
+ // tag characters in U+E0020–U+E007E, terminated by U+E007F CANCEL TAG. Only that
318
+ // exact grammar is preserved verbatim; any tag char (the whole U+E0000–U+E007F
319
+ // block is category Cf) NOT inside a well-formed 🏴 … CANCEL run stays stripped —
320
+ // tag chars are the top ASCII-smuggling vector, so preservation fails CLOSED on a
321
+ // malformed/partial run.
322
+ const TAG_BASE = 0x1f3f4; // WAVING BLACK FLAG
323
+ const TAG_CANCEL = 0xe007f; // CANCEL TAG (the required terminator)
324
+ const TAG_SPEC_MIN = 0xe0020; // first tag char a flag sequence may carry
325
+ const TAG_SPEC_MAX = 0xe007e; // last tag char a flag sequence may carry
326
+ // A tag char decodes to ASCII (cp − 0xE0000), so a grammatically-valid
327
+ // 🏴 … CANCEL run can spell ARBITRARY ASCII ("ignore rules") and, if preserved
328
+ // only on grammar, smuggle it verbatim to the model. Preservation is therefore
329
+ // gated on the decoded payload naming a REGISTERED subdivision (the only tag
330
+ // sequences that render as a real flag) and on a tag-char cap — every other
331
+ // run, however well-formed, stays stripped (fail closed on the top ASCII vector).
332
+ const REGISTERED_TAG_PAYLOADS = new Set(["gbeng", "gbsct", "gbwls"]);
333
+ // Longest registered subdivision payload is 5 tag chars; cap a touch above it so
334
+ // a malformed-but-registered-prefix run can never carry a long ASCII rider.
335
+ const MAX_TAG_SPEC_CHARS = 6;
336
+
337
+ // ─── Ideographic variation selectors (VS17–VS256, U+E0100–U+E01EF) ────────────
338
+ // An ideographic variation sequence is a CJK ideograph followed by a selector in
339
+ // U+E0100–U+E01EF. Preserved ONLY when the immediately preceding code point is a
340
+ // CJK ideograph — the registry-faithful structural gate (IVS apply to ideographs
341
+ // and nothing else). The ranges are the Unicode ideograph blocks: Unified,
342
+ // Extensions A–I, and the two Compatibility Ideograph blocks.
343
+ const IVS_MIN = 0xe0100;
344
+ const IVS_MAX = 0xe01ef;
345
+ const CJK_IDEOGRAPH_RANGES = [
346
+ [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
347
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
348
+ [0xf900, 0xfaff], // CJK Compatibility Ideographs
349
+ [0x20000, 0x2a6df], // Extension B
350
+ [0x2a700, 0x2b73f], // Extension C
351
+ [0x2b740, 0x2b81f], // Extension D
352
+ [0x2b820, 0x2ceaf], // Extension E
353
+ [0x2ceb0, 0x2ebef], // Extension F
354
+ [0x2ebf0, 0x2ee5f], // Extension I
355
+ [0x2f800, 0x2fa1f], // CJK Compatibility Ideographs Supplement
356
+ [0x30000, 0x3134f], // Extension G
357
+ [0x31350, 0x323af], // Extension H
358
+ ];
359
+
360
+ /** True when `cp` is a CJK ideograph (the only base an ideographic variation
361
+ * selector legitimately follows). @param {number} cp @returns {boolean} */
362
+ function isCjkIdeograph(cp) {
363
+ for (const [start, end] of CJK_IDEOGRAPH_RANGES)
364
+ if (cp >= start && cp <= end) return true;
365
+ return false;
366
+ }
367
+
368
+ // Consonant (KA..HA and script-specific additional-consonant) ranges of the
369
+ // Brahmic scripts the joiner carve-out serves. A virama does half-form/conjunct
370
+ // work ONLY on a consonant base; a bare or base-less halant + ZWJ carries no
371
+ // rendering and is a smuggling channel, so the Indic joiner is preserved only
372
+ // when its virama sits on one of these. Broad per-block spans — precision here
373
+ // only needs "a real Brahmic letter of this script", not an exact consonant set.
374
+ const BRAHMIC_CONSONANT_RANGES = [
375
+ [0x0915, 0x0939], // Devanagari KA–HA
376
+ [0x0958, 0x095f], // Devanagari additional consonants
377
+ [0x0995, 0x09b9], // Bengali
378
+ [0x09dc, 0x09df], // Bengali additional consonants
379
+ [0x0a15, 0x0a39], // Gurmukhi
380
+ [0x0a59, 0x0a5e], // Gurmukhi additional consonants
381
+ [0x0a95, 0x0ab9], // Gujarati
382
+ [0x0b15, 0x0b39], // Oriya
383
+ [0x0b5c, 0x0b5f], // Oriya additional consonants
384
+ [0x0b95, 0x0bb9], // Tamil
385
+ [0x0c15, 0x0c39], // Telugu
386
+ [0x0c58, 0x0c5a], // Telugu additional consonants
387
+ [0x0c95, 0x0cb9], // Kannada
388
+ [0x0d15, 0x0d3a], // Malayalam
389
+ [0x0d9a, 0x0dc6], // Sinhala
390
+ ];
391
+
392
+ /** True when `cp` is a Brahmic consonant — the only base a virama attaches to.
393
+ * @param {number} cp @returns {boolean} */
394
+ function isBrahmicConsonant(cp) {
395
+ for (const [start, end] of BRAHMIC_CONSONANT_RANGES)
396
+ if (cp >= start && cp <= end) return true;
397
+ return false;
398
+ }
399
+
400
+ // ─── Blank-filler carve-out (Braille / archaic Hangul) ───────────────────────
401
+ // U+2800 (BRAILLE PATTERN BLANK) and the Hangul fillers render blank, so a RUN
402
+ // of them is a hidden channel — but a lone one does real work in genuine Braille
403
+ // (the empty cell) or archaic-Korean text, where stripping it mangles content.
404
+ // Gate them like the joiner carve-out: preserve one only next to a real,
405
+ // script-appropriate visible neighbour (a non-blank Braille cell; a Hangul
406
+ // jamo/syllable). A run of fillers has only fillers for neighbours, so it fails
407
+ // the anchor and is stripped — the run-length gate falls out of the anchor. The
408
+ // zero-width Mn marks in BLANK_NON_CF (U+034F/17B4/17B5) have no such benign
409
+ // standalone use, so they are never preserved.
410
+ const BRAILLE_BLANK = 0x2800;
411
+ const HANGUL_FILLERS = new Set([0x115f, 0x1160, 0x3164, 0xffa0]);
412
+ // Code points that trigger the carve-out path for blank fillers (see
413
+ // needsCarveOut). Written as \u escapes: a raw blank-rendering byte in a regex
414
+ // literal is invisible to the eye and a correctness landmine for the next editor.
415
+ const GATED_BLANK_RE = new RegExp("[\\u115F\\u1160\\u2800\\u3164\\uFFA0]", "u");
416
+
417
+ /** A real (non-blank) Braille cell — the anchoring neighbour for a U+2800 blank.
418
+ * @param {string} ch @returns {boolean} */
419
+ function isBrailleCell(ch) {
420
+ const cp = ch ? /** @type {number} */ (ch.codePointAt(0)) : -1;
421
+ return cp >= 0x2801 && cp <= 0x28ff;
422
+ }
423
+
424
+ /** A Hangul jamo/syllable (NOT itself one of the fillers) — the anchoring
425
+ * neighbour for a Hangul filler. @param {string} ch @returns {boolean} */
426
+ function isHangul(ch) {
427
+ const cp = ch ? /** @type {number} */ (ch.codePointAt(0)) : -1;
428
+ if (HANGUL_FILLERS.has(cp)) return false; // a filler cannot anchor another filler
429
+ return (
430
+ (cp >= 0x1100 && cp <= 0x11ff) || // Hangul Jamo
431
+ (cp >= 0x3130 && cp <= 0x318f) || // Hangul Compatibility Jamo
432
+ (cp >= 0xa960 && cp <= 0xa97f) || // Jamo Extended-A
433
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
434
+ (cp >= 0xd7b0 && cp <= 0xd7ff) || // Jamo Extended-B
435
+ (cp >= 0xffa1 && cp <= 0xffdc) // Halfwidth Jamo (FFA0 is the filler itself)
436
+ );
437
+ }
438
+
439
+ // Non-global single-char classifiers (CHECKS carry `g`, whose lastIndex is
440
+ // stateful across `.test`). carveStrip uses these to attribute each removed
441
+ // char to its CHECKS category so `found` names exactly what was stripped.
442
+ const CHECK_ONE = CHECKS.map(
443
+ ([code, re]) =>
444
+ /** @type {[string, RegExp]} */ ([code, new RegExp(re.source, "u")]),
445
+ );
446
+
447
+ /**
448
+ * The CHECKS category code (a CATEGORY value) a single code point belongs to,
449
+ * or null when it is not payload-capable (an ordinary visible character).
450
+ * @param {string} ch one code point
451
+ * @returns {string | null}
452
+ */
453
+ function classify(ch) {
454
+ for (const [code, re] of CHECK_ONE) if (re.test(ch)) return code;
455
+ return null;
456
+ }
457
+
458
+ /** A cursive-joining letter: Joining_Type dual, right, or left. (C is a join
459
+ * control, T is a transparent mark, U is non-joining — none is a letter that a
460
+ * ZWNJ/ZWJ does rendering work between.)
461
+ * @param {string} jt @returns {boolean} */
462
+ const isCursiveLetter = (jt) => jt === "D" || jt === "R" || jt === "L";
463
+
464
+ /** The Joining_Type of a single-code-point string, or "U" for "" (boundary).
465
+ * @param {string} ch @returns {string} */
466
+ const jtOf = (ch) =>
467
+ ch ? joiningType(/** @type {number} */ (ch.codePointAt(0))) : "U";
468
+
469
+ /** True when `ch` is itself a ZWNJ/ZWJ (used to reject joiner runs).
470
+ * @param {string} ch @returns {boolean} */
471
+ function isJoinControl(ch) {
472
+ const cp = ch ? ch.codePointAt(0) : -1;
473
+ return cp === ZWNJ || cp === ZWJ;
474
+ }
475
+
476
+ /**
477
+ * The nearest neighbour of index `i` in direction `dir`, skipping Transparent
478
+ * (combining-mark) code points, as the Unicode cursive-joining algorithm does —
479
+ * a harakat between a letter and a ZWNJ does not break the join. Also skips any
480
+ * OTHER tracked invisible that is itself removable (blank fillers, format
481
+ * chars): it does no cursive-shaping work and gets stripped, so a joiner's real
482
+ * neighbour is the next surviving character. Without this a payload byte wedged
483
+ * between two joiners hides the joiner-RUN until a first pass removes it, after
484
+ * which a second pass strips the now-adjacent joiners — non-idempotent. Join
485
+ * controls (ZWNJ/ZWJ) are deliberately NOT skipped: they are the run signal.
486
+ * Returns "" past the string boundary.
487
+ * @param {string[]} cps @param {number} i @param {number} dir -1 or +1
488
+ * @returns {string}
489
+ */
490
+ function effectiveNeighbor(cps, i, dir) {
491
+ for (let j = i + dir; j >= 0 && j < cps.length; j += dir) {
492
+ const ch = cps[j];
493
+ if (jtOf(ch) === "T") continue;
494
+ if (!isJoinControl(ch) && classify(ch) !== null) continue;
495
+ return ch;
496
+ }
497
+ return "";
498
+ }
499
+
500
+ /**
501
+ * True when the joiner at `cps[i]` follows a virama that itself sits on a real
502
+ * Brahmic consonant — a genuine half-form/conjunct request (क् + ZWJ), not a
503
+ * bare or base-less halant (a smuggling channel a raw `cps[i-1]` virama test
504
+ * would wave through). Steps left over removable invisibles to LAND on the
505
+ * virama (which is Joining_Type Transparent, so it is the anchor, never skipped),
506
+ * then uses effectiveNeighbor to find the consonant base past any transparent
507
+ * marks / invisibles between it and the virama.
508
+ * @param {string[]} cps @param {number} i
509
+ * @returns {boolean}
510
+ */
511
+ function followsBrahmicConjunct(cps, i) {
512
+ let j = i - 1;
513
+ while (j >= 0 && !isJoinControl(cps[j]) && classify(cps[j]) !== null) j--;
514
+ if (j < 0 || !isVirama(/** @type {number} */ (cps[j].codePointAt(0))))
515
+ return false;
516
+ const base = effectiveNeighbor(cps, j, -1);
517
+ return (
518
+ base !== "" &&
519
+ isBrahmicConsonant(/** @type {number} */ (base.codePointAt(0)))
520
+ );
521
+ }
522
+
523
+ /**
524
+ * True when the joiner at `cps[i]` does real rendering work and so must be
525
+ * preserved rather than stripped, decided from the neighbours' Joining_Type:
526
+ * - emoji ZWJ: between two emoji components (its left pictograph may sit behind
527
+ * a VS16, so step over selectors — see leftNonSelector);
528
+ * - Indic joiner: after a virama that sits on a real Brahmic consonant;
529
+ * - Arabic-family joiner: between two cursive letters (ZWNJ needs both, ZWJ at
530
+ * least one — it forces a connected form). A joiner whose effective neighbour
531
+ * is ANOTHER joiner is a run (a zero-width payload channel) and is rejected.
532
+ * Leading/trailing joiners fall out because "" has Joining_Type U.
533
+ * @param {string[]} cps @param {number} i
534
+ * @returns {boolean}
535
+ */
536
+ function isPreservedJoiner(cps, i) {
537
+ const cp = /** @type {number} */ (cps[i].codePointAt(0));
538
+ if (cp !== ZWNJ && cp !== ZWJ) return false;
539
+ // Emoji ZWJ sequences use ZWJ only; the real neighbours are the pictographs,
540
+ // which may each sit behind a variation selector (🏳️‍🌈 = base VS16 ZWJ rainbow;
541
+ // a selector can also follow the ZWJ before the next base), so step over
542
+ // selectors on BOTH sides — leftNonSelector and its mirror rightNonSelector.
543
+ if (
544
+ cp === ZWJ &&
545
+ EMOJI_LEFT.test(leftNonSelector(cps, i)) &&
546
+ EMOJI_BASE.test(rightNonSelector(cps, i))
547
+ )
548
+ return true;
549
+ // Indic: meaningful only after a virama sitting on a real consonant base.
550
+ if (followsBrahmicConjunct(cps, i)) return true;
551
+ // Arabic-family cursive joining, on the nearest non-Transparent neighbours.
552
+ const left = effectiveNeighbor(cps, i, -1);
553
+ const right = effectiveNeighbor(cps, i, 1);
554
+ if (isJoinControl(left) || isJoinControl(right)) return false; // joiner run
555
+ const lc = isCursiveLetter(jtOf(left));
556
+ const rc = isCursiveLetter(jtOf(right));
557
+ return cp === ZWNJ ? lc && rc : lc || rc;
558
+ }
559
+
560
+ /**
561
+ * True when `cps[i]` is a presentation selector (VS15 U+FE0E or VS16 U+FE0F)
562
+ * that is part of a visible glyph, not a hidden VS run:
563
+ * - directly after a pictograph/skin-tone modifier, preserved; OR
564
+ * - directly after a keycap base (digit/`#`/`*`) AND immediately followed by
565
+ * U+20E3 COMBINING ENCLOSING KEYCAP — a complete keycap glyph (1️⃣ #️⃣ …).
566
+ * A keycap base + selector with NO trailing U+20E3 is a bare hidden presentation
567
+ * selector, so it fails closed and is stripped. A longer selector run still
568
+ * surfaces: the next selector's left neighbour is itself a selector.
569
+ * @param {string[]} cps @param {number} i
570
+ * @returns {boolean}
571
+ */
572
+ function isEmojiPresentationSelector(cps, i) {
573
+ if (
574
+ !PRESENTATION_SELECTORS.has(/** @type {number} */ (cps[i].codePointAt(0)))
575
+ )
576
+ return false;
577
+ const prev = cps[i - 1] ?? "";
578
+ if (EMOJI_LEFT.test(prev)) return true;
579
+ return (
580
+ KEYCAP_BASE.test(prev) &&
581
+ (cps[i + 1]?.codePointAt(0) ?? -1) === COMBINING_KEYCAP
582
+ );
583
+ }
584
+
585
+ /**
586
+ * Per-invisible carve-out analysis, shared by carveStrip and
587
+ * countPayloadInvisible: for each code point, its CHECKS category (null when
588
+ * visible) and its preserve `kind` ("joiner" | "emojivs" | "tag" | "stdvs" |
589
+ * "ivs" | "blank" | null). Everything invisible that is NOT preserve-eligible is
590
+ * payload; the scatter floor counts only that, so meaningful joiners/selectors
591
+ * never push honest prose over the threshold. `tagSpanLen[i]` is the length of a
592
+ * tag sequence starting at `i` (0 elsewhere) so carveStrip can preserve-or-strip
593
+ * each flag as an atomic unit (a budget cut mid-sequence would leave a malformed
594
+ * partial run the next pass would strip — breaking idempotence).
595
+ * @param {string[]} cps
596
+ * @returns {{ codes: (string|null)[], kind: (string|null)[], tagSpanLen: number[], payloadInvis: number, visibleLen: number }}
597
+ */
598
+ function analyzeCarve(cps) {
599
+ const codes = cps.map(classify);
600
+ const tagKeep = markTagSequences(cps);
601
+ const kind = cps.map((_, i) => {
602
+ if (codes[i] === null) return null;
603
+ if (tagKeep[i]) return "tag";
604
+ if (isPreservedJoiner(cps, i)) return "joiner";
605
+ if (isEmojiPresentationSelector(cps, i)) return "emojivs";
606
+ if (isStandardizedVariationSelector(cps, i)) return "stdvs";
607
+ if (isIdeographicVariationSelector(cps, i)) return "ivs";
608
+ if (isPreservedBlankFiller(cps, i)) return "blank";
609
+ return null;
610
+ });
611
+ const tagSpanLen = new Array(cps.length).fill(0);
612
+ for (let i = 0; i < cps.length;) {
613
+ if (kind[i] !== "tag") {
614
+ i++;
615
+ continue;
616
+ }
617
+ let j = i;
618
+ while (j < cps.length && kind[j] === "tag") j++;
619
+ tagSpanLen[i] = j - i;
620
+ i = j;
621
+ }
622
+ let payloadInvis = 0;
623
+ let visibleLen = 0;
624
+ for (let i = 0; i < cps.length; i++) {
625
+ if (codes[i] === null) visibleLen++;
626
+ else if (kind[i] === null) payloadInvis++;
627
+ }
628
+ return { codes, kind, tagSpanLen, payloadInvis, visibleLen };
629
+ }
630
+
631
+ /**
632
+ * Count the PAYLOAD invisible code points in `text`: those the carve-out would
633
+ * strip, excluding ZWNJ/ZWJ (and emoji VS16) that do real rendering work.
634
+ * Consumers that gate on invisible density (e.g. the prompt classifier's scatter
635
+ * threshold) use this so legitimate dense multilingual prose is not mistaken for
636
+ * a hidden channel.
637
+ * @param {string} text
638
+ * @returns {number}
639
+ */
640
+ export function countPayloadInvisible(text) {
641
+ return analyzeCarve(Array.from(text)).payloadInvis;
642
+ }
643
+
644
+ /**
645
+ * Bulk strip (the common path: {@link needsCarveOut} found nothing the
646
+ * carve-out could apply to). A single regex pass removes every payload-
647
+ * capable char; `found` names the category codes present via `.search`
648
+ * (which ignores the `g` lastIndex).
649
+ * @param {string} body
650
+ * @returns {{ cleaned: string, found: string[] }}
651
+ */
652
+ function bulkStrip(body) {
653
+ const found = CHECKS.filter(([, re]) => body.search(re) !== -1).map(
654
+ ([code]) => code,
655
+ );
656
+ return { cleaned: body.replace(STRIP, ""), found };
657
+ }
658
+
659
+ /**
660
+ * The nearest code point left of index `i` that is not a variation selector, or
661
+ * "" at the string start. An emoji ZWJ sequence can place a VS16 between the base
662
+ * pictograph and the ZWJ, so the joiner's real left neighbor is found by stepping
663
+ * over any variation selector(s).
664
+ * @param {string[]} cps
665
+ * @param {number} i
666
+ * @returns {string}
667
+ */
668
+ function leftNonSelector(cps, i) {
669
+ let p = i - 1;
670
+ while (p >= 0 && VARIATION_SELECTOR.test(cps[p])) p--;
671
+ return cps[p] ?? "";
672
+ }
673
+
674
+ /**
675
+ * The nearest code point right of index `i` that is not a variation selector, or
676
+ * "" at the string end. Mirror of {@link leftNonSelector}: an emoji ZWJ can be
677
+ * followed by a selector before the next component's base pictograph, so the
678
+ * joiner's real right neighbor is found by stepping over any variation
679
+ * selector(s).
680
+ * @param {string[]} cps
681
+ * @param {number} i
682
+ * @returns {string}
683
+ */
684
+ function rightNonSelector(cps, i) {
685
+ let p = i + 1;
686
+ while (p < cps.length && VARIATION_SELECTOR.test(cps[p])) p++;
687
+ return cps[p] ?? "";
688
+ }
689
+
690
+ /**
691
+ * Mark every index that belongs to a PRESERVABLE emoji tag sequence (subregional
692
+ * flag): a U+1F3F4 tag_base, then tag chars in U+E0020–U+E007E whose decoded
693
+ * ASCII payload names a REGISTERED subdivision, then U+E007F CANCEL TAG. Only the
694
+ * tag chars and the terminating CANCEL are marked (the base is a visible
695
+ * pictograph). A run missing the CANCEL, exceeding the tag-char cap, or whose
696
+ * payload is not a registered subdivision is left unmarked so it is stripped —
697
+ * grammatical validity alone is NOT enough, since a valid run spells arbitrary
698
+ * ASCII (fail closed on the top ASCII-smuggling vector).
699
+ * @param {string[]} cps
700
+ * @returns {boolean[]} keep[i] true iff `cps[i]` is inside a preservable tag sequence
701
+ */
702
+ function markTagSequences(cps) {
703
+ const keep = new Array(cps.length).fill(false);
704
+ /** @param {number} k @returns {number} */
705
+ const cpAt = (k) => /** @type {number} */ (cps[k].codePointAt(0));
706
+ for (let i = 0; i < cps.length; i++) {
707
+ if (cpAt(i) !== TAG_BASE) continue;
708
+ let j = i + 1;
709
+ let payload = "";
710
+ while (
711
+ j < cps.length &&
712
+ cpAt(j) >= TAG_SPEC_MIN &&
713
+ cpAt(j) <= TAG_SPEC_MAX
714
+ ) {
715
+ payload += String.fromCharCode(cpAt(j) - 0xe0000);
716
+ j++;
717
+ }
718
+ const tagLen = j - (i + 1);
719
+ // Require ≥1 tag char, at most MAX_TAG_SPEC_CHARS, a terminating CANCEL, and
720
+ // a payload naming a registered subdivision — else the run stays stripped.
721
+ if (
722
+ tagLen >= 1 &&
723
+ tagLen <= MAX_TAG_SPEC_CHARS &&
724
+ j < cps.length &&
725
+ cpAt(j) === TAG_CANCEL &&
726
+ REGISTERED_TAG_PAYLOADS.has(payload)
727
+ ) {
728
+ for (let k = i + 1; k <= j; k++) keep[k] = true;
729
+ i = j; // resume after the consumed sequence
730
+ }
731
+ }
732
+ return keep;
733
+ }
734
+
735
+ /**
736
+ * True when `cps[i]` is a standardized variation selector (U+FE00–U+FE0D) whose
737
+ * immediately preceding code point forms a REGISTERED standardized variation
738
+ * sequence (per the generated UCD table). Every unregistered FE00–FE0D selector
739
+ * stays payload.
740
+ * @param {string[]} cps @param {number} i
741
+ * @returns {boolean}
742
+ */
743
+ function isStandardizedVariationSelector(cps, i) {
744
+ const cp = /** @type {number} */ (cps[i].codePointAt(0));
745
+ if (cp < 0xfe00 || cp > 0xfe0d) return false;
746
+ const prev = cps[i - 1];
747
+ return prev
748
+ ? isStandardizedVariant(/** @type {number} */ (prev.codePointAt(0)), cp)
749
+ : false;
750
+ }
751
+
752
+ /**
753
+ * True when `cps[i]` is an ideographic variation selector (VS17–VS256,
754
+ * U+E0100–U+E01EF) immediately after a CJK ideograph — the registry-faithful
755
+ * structural gate for an ideographic variation sequence.
756
+ * @param {string[]} cps @param {number} i
757
+ * @returns {boolean}
758
+ */
759
+ function isIdeographicVariationSelector(cps, i) {
760
+ const cp = /** @type {number} */ (cps[i].codePointAt(0));
761
+ if (cp < IVS_MIN || cp > IVS_MAX) return false;
762
+ const prev = cps[i - 1];
763
+ return prev
764
+ ? isCjkIdeograph(/** @type {number} */ (prev.codePointAt(0)))
765
+ : false;
766
+ }
767
+
768
+ /**
769
+ * True when `cps[i]` is a Braille blank (U+2800) or a Hangul filler that sits
770
+ * next to a real, script-appropriate visible neighbour — a genuine empty Braille
771
+ * cell or archaic-Korean filler, not a hidden run. The zero-width Mn marks in
772
+ * BLANK_NON_CF have no such anchored use and are never preserved here.
773
+ * @param {string[]} cps @param {number} i
774
+ * @returns {boolean}
775
+ */
776
+ function isPreservedBlankFiller(cps, i) {
777
+ const cp = /** @type {number} */ (cps[i].codePointAt(0));
778
+ const prev = cps[i - 1] ?? "";
779
+ const next = cps[i + 1] ?? "";
780
+ if (cp === BRAILLE_BLANK) return isBrailleCell(prev) || isBrailleCell(next);
781
+ if (HANGUL_FILLERS.has(cp)) return isHangul(prev) || isHangul(next);
782
+ return false;
783
+ }
784
+
785
+ /**
786
+ * Carve-out strip (an invisible the carve-out might preserve is present): walk
787
+ * code points, preserving a joiner/selector/tag/blank-filler only where its
788
+ * `kind` is set AND the text stays under the scatter floor AND neither the
789
+ * per-cluster (CONSECUTIVE_JOINER_CAP) nor the document-wide
790
+ * (TOTAL_PRESERVED_JOINER_BUDGET) preserve limit is hit — otherwise it is
791
+ * stripped like any other payload byte. A tag (subregional-flag) sequence is
792
+ * preserved-or-stripped ATOMICALLY: preserving only part of it would leave a
793
+ * malformed run the next pass strips, breaking idempotence. `found` reports only
794
+ * categories actually removed, so a preserved char never makes the caller claim
795
+ * a strip that did not happen, and a stuffed channel surfaces as its category
796
+ * once it overruns the budget.
797
+ * @param {string} body
798
+ * @returns {{ cleaned: string, found: string[] }}
799
+ */
800
+ function carveStrip(body) {
801
+ const cps = Array.from(body);
802
+ // Pass 1: classify + evaluate the gate once (see analyzeCarve). Only PAYLOAD
803
+ // invisibles count toward the scatter floor, so a meaningful-joiner-dense text
804
+ // (formal Persian, a long Devanagari conjunct run) stays under it.
805
+ const { codes, kind, tagSpanLen, payloadInvis, visibleLen } =
806
+ analyzeCarve(cps);
807
+ // SCATTERED_THRESHOLD is the floor on payload invisibles: past it the document
808
+ // is drowning in hidden bytes, so the carve-out is off and even a meaningful
809
+ // joiner is stripped (threshold-evasion catch — over-strip beats under).
810
+ const allowCarveOut = payloadInvis < SCATTERED_THRESHOLD;
811
+ // Document-wide preserve allowance, proportional to visible text but never
812
+ // below the floor (see TOTAL_PRESERVED_JOINER_BUDGET / PRESERVED_JOINER_PER_VISIBLE)
813
+ // and never above PRESERVE_HARD_CAP — the absolute ceiling stops the channel
814
+ // scaling with attacker-supplied cover text.
815
+ const maxPreserved = Math.min(
816
+ PRESERVE_HARD_CAP,
817
+ Math.max(
818
+ TOTAL_PRESERVED_JOINER_BUDGET,
819
+ Math.ceil(visibleLen / PRESERVED_JOINER_PER_VISIBLE),
820
+ ),
821
+ );
822
+
823
+ const foundCodes = new Set();
824
+ let out = "";
825
+ // Preserved JOINERS in the current uninterrupted cluster (tags/blank fillers
826
+ // and presentation selectors don't chain, so they are exempt). A genuine gap
827
+ // (two visible chars in a row — see prevVisible) resets it; past the cap the
828
+ // surplus is stripped.
829
+ let joinerRun = 0;
830
+ // Preserved IVS/standardized variation selectors in the current uninterrupted
831
+ // run (an `ideograph selector ideograph selector …` chain never breaks the
832
+ // prevVisible gap, so joinerRun's reset does not bound it — this counter does).
833
+ // Same genuine-gap reset as joinerRun; past CONSECUTIVE_SELECTOR_CAP the
834
+ // surplus is stripped, closing the high-bit-rate variation-selector channel.
835
+ let selectorRun = 0;
836
+ // Preserved chars across the WHOLE string — never reset at a gap, so it bounds
837
+ // the document-wide channel joinerRun cannot. Past maxPreserved a preserved
838
+ // char is stripped and its category reported.
839
+ let preservedTotal = 0;
840
+ let prevVisible = false;
841
+ let i = 0;
842
+ while (i < cps.length) {
843
+ const code = codes[i];
844
+ if (code === null) {
845
+ // A visible char following another visible char is a real word/segment
846
+ // boundary, not a join — the joined cluster (if any) ended here.
847
+ if (prevVisible) {
848
+ joinerRun = 0;
849
+ selectorRun = 0;
850
+ }
851
+ prevVisible = true;
852
+ out += cps[i]; // ordinary visible character
853
+ i++;
854
+ continue;
855
+ }
856
+ // A tag (subregional-flag) sequence: atomic preserve-or-strip on the whole
857
+ // run so a budget cut can't leave a malformed partial run (idempotence).
858
+ if (kind[i] === "tag") {
859
+ const len = tagSpanLen[i];
860
+ const fits = allowCarveOut && preservedTotal + len <= maxPreserved;
861
+ for (let k = 0; k < len; k++) {
862
+ if (fits) out += cps[i + k];
863
+ else foundCodes.add(codes[i + k]);
864
+ }
865
+ if (fits) preservedTotal += len;
866
+ prevVisible = false; // the sequence keeps the cluster open
867
+ i += len;
868
+ continue;
869
+ }
870
+ const joiner = kind[i] === "joiner";
871
+ const selector = kind[i] === "ivs" || kind[i] === "stdvs";
872
+ if (
873
+ allowCarveOut &&
874
+ kind[i] !== null &&
875
+ preservedTotal < maxPreserved &&
876
+ (!joiner || joinerRun < CONSECUTIVE_JOINER_CAP) &&
877
+ (!selector || selectorRun < CONSECUTIVE_SELECTOR_CAP)
878
+ ) {
879
+ if (joiner) joinerRun++;
880
+ if (selector) selectorRun++;
881
+ preservedTotal++;
882
+ prevVisible = false; // a joiner/selector keeps the cluster open
883
+ out += cps[i];
884
+ i++;
885
+ continue;
886
+ }
887
+ foundCodes.add(code);
888
+ prevVisible = false; // a stripped invisible neither opens nor closes a gap
889
+ i++;
890
+ }
891
+ const found = CHECKS.filter(([code]) => foundCodes.has(code)).map(
892
+ ([code]) => code,
893
+ );
894
+ return { cleaned: out, found };
895
+ }
896
+
897
+ // Any tag char (the whole U+E0000–U+E007F block is category Cf).
898
+ const TAG_CHAR_RE = /[\u{E0000}-\u{E007F}]/u;
899
+
900
+ /**
901
+ * True when `body` holds anything the carve-out in {@link carveStrip} might
902
+ * preserve: a ZWNJ/ZWJ, ANY variation selector (a registered standardized or
903
+ * ideographic sequence, or an emoji presentation selector — VARIATION_SELECTOR
904
+ * covers FE00–FE0F and E0100–E01EF), a tag char (a subregional flag), or a
905
+ * gated blank filler (Braille/archaic-Hangul). Each needs the per-neighbor
906
+ * analysis, or bulkStrip would strip it unconditionally and corrupt a
907
+ * legitimate glyph.
908
+ * @param {string} body
909
+ * @returns {boolean}
910
+ */
911
+ function needsCarveOut(body) {
912
+ return (
913
+ body.includes(String.fromCodePoint(ZWNJ)) ||
914
+ body.includes(String.fromCodePoint(ZWJ)) ||
915
+ VARIATION_SELECTOR.test(body) ||
916
+ TAG_CHAR_RE.test(body) ||
917
+ GATED_BLANK_RE.test(body)
918
+ );
919
+ }
920
+
921
+ /**
922
+ * The `text` with every carve-out-PRESERVABLE invisible (joiners/selectors/tags/
923
+ * blank fillers doing real rendering work) replaced by a space, leaving only the
924
+ * PAYLOAD invisibles in place. The LONG_RUN injection probe runs over this so a
925
+ * legitimate emoji/flag/variation sequence never trips the "possible injection
926
+ * payload" marker (alert fatigue), while a genuine hidden run still surfaces.
927
+ * @param {string} text
928
+ * @returns {string}
929
+ */
930
+ export function payloadInvisibleView(text) {
931
+ const cps = Array.from(text);
932
+ const { codes, kind } = analyzeCarve(cps);
933
+ let out = "";
934
+ for (let i = 0; i < cps.length; i++)
935
+ out += codes[i] !== null && kind[i] === null ? cps[i] : " ";
936
+ return out;
937
+ }
938
+
939
+ /**
940
+ * Strip payload-capable invisible chars and report which categories were
941
+ * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
942
+ * interior BOMs and all soft hyphens (U+00AD) are stripped, since either can
943
+ * encode hidden instructions. ZWNJ/ZWJ survive only in a linguistic context
944
+ * (see the carve-out above). `found` names exactly the categories stripped, so
945
+ * a caller never warns about a strip the carve-out skipped.
946
+ *
947
+ * `originalText` is the pre-processing text (before any ANSI strip) used ONLY to
948
+ * decide whether a leading BOM is genuinely leading: an interior BOM that an
949
+ * ANSI-strip left at index 0 of `text` (e.g. `ESC[m + interior U+FEFF`) must NOT be treated as a
950
+ * legitimate leading marker. Defaults to `text` for the common single-arg call.
951
+ * @param {string} text
952
+ * @param {string} [originalText]
953
+ * @returns {{ cleaned: string, found: string[] }}
954
+ */
955
+ export function stripInvisibleWithReport(text, originalText = text) {
956
+ // Leading only when the BOM leads the ORIGINAL text (not merely `text` after an
957
+ // ANSI strip shifted an interior BOM to index 0). The `text` guard keeps the
958
+ // slice sound when the two disagree.
959
+ const hasLeadingBom =
960
+ originalText.charCodeAt(0) === 0xfeff && text.charCodeAt(0) === 0xfeff;
961
+ const body = hasLeadingBom ? text.slice(1) : text;
962
+ const { cleaned, found } = needsCarveOut(body)
963
+ ? carveStrip(body)
964
+ : bulkStrip(body);
965
+ return { cleaned: hasLeadingBom ? BOM + cleaned : cleaned, found };
966
+ }
967
+
968
+ /**
969
+ * Strip payload-capable invisible chars (cleaned text only). See
970
+ * stripInvisibleWithReport for the BOM and ZWNJ/ZWJ carve-out semantics.
971
+ * @param {string} text
972
+ * @returns {string}
973
+ */
974
+ export function stripInvisible(text) {
975
+ return stripInvisibleWithReport(text).cleaned;
976
+ }