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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
|
@@ -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
|
+
}
|