agent-sanitizer 2.58.8 → 2.58.10
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/THREAT-MODEL.md +5 -3
- package/package.json +1 -1
- package/python/agent_sanitizer/data/invisible-charset.json +1 -1
- package/src/ansi.mjs +121 -58
- package/src/layer1.mjs +21 -46
- package/types/ansi.d.mts +18 -8
- package/types/layer1.d.mts +13 -12
- package/types/src/ansi.d.mts +18 -8
package/THREAT-MODEL.md
CHANGED
|
@@ -54,9 +54,11 @@ table](./README.md#entry-points) maps each to its import.
|
|
|
54
54
|
|
|
55
55
|
**Reassembly hardening.** The two passes feed each other in _both_ directions:
|
|
56
56
|
stripping an invisible char can reconstitute an ANSI escape its split had
|
|
57
|
-
hidden, removing
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
hidden, and removing an ANSI sequence can make two invisibles adjacent that were
|
|
58
|
+
not—a joiner run the invisible pass treats as a payload channel rather than as
|
|
59
|
+
linguistic. One ANSI pass alone is already final: each token ends where a
|
|
60
|
+
terminal's parser ends it, and an introducer that the next one cancels is
|
|
61
|
+
removed with it, so no removal joins the remainder into a new sequence. Layer 1
|
|
60
62
|
does not run a fixed sequence of passes; it iterates the whole
|
|
61
63
|
{ANSI, invisible} composition to a fixed point (bounded, since each round
|
|
62
64
|
deletes at least one character), and only once that is stable does it sweep any
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.58.
|
|
3
|
+
"version": "2.58.10",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -478,5 +478,5 @@
|
|
|
478
478
|
158,
|
|
479
479
|
159
|
|
480
480
|
],
|
|
481
|
-
"escape_sequence_pattern": "(?:(?:\\u001b[\\u005d\\u0050\\u0058\\u005e\\u005f]|[\\u0090\\u0098\\u009d\\u009e\\u009f])[^\\u0007\\u000a\\u000d\\u0018\\u001a\\u001b\\u0090\\u0098\\u009c\\u009d\\u009e\\u009f]*(?:[\\u0007\\u0018\\u001a\\u009c]|\\u001b\\u005c|(?=[\\u000a\\u000d\\u001b\\u0090\\u0098\\u009d\\u009e\\u009f])|(?![\\s\\S]))|
|
|
481
|
+
"escape_sequence_pattern": "(?:(?:\\u001b[\\u005d\\u0050\\u0058\\u005e\\u005f]|[\\u0090\\u0098\\u009d\\u009e\\u009f])[^\\u0007\\u000a\\u000d\\u0018\\u001a\\u001b\\u0090\\u0098\\u009c\\u009d\\u009e\\u009f]*(?:[\\u0007\\u0018\\u001a\\u009c]|\\u001b\\u005c|(?=[\\u000a\\u000d\\u001b\\u0090\\u0098\\u009d\\u009e\\u009f])|(?![\\s\\S]))|(?:\\u001b\\[|\\u009b)[ -?]*(?:[@-~]|[\\u0018\\u001a]|(?=[\\u001b\\u0080\\u0081\\u0082\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c\\u009d\\u009e\\u009f]))|\\u001b(?:[ -/]+(?:[0-~]|[\\u0018\\u001a]|(?=[\\u001b\\u0080\\u0081\\u0082\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c\\u009d\\u009e\\u009f]))|[\\u0030\\u0031\\u0032\\u0033\\u0034\\u0035\\u0036\\u0037\\u0038\\u0039\\u003a\\u003b\\u003c\\u003d\\u003e\\u003f\\u0040\\u0041\\u0042\\u0043\\u0044\\u0045\\u0046\\u0047\\u0048\\u0049\\u004a\\u004b\\u004c\\u004d\\u004e\\u004f\\u0051\\u0052\\u0053\\u0054\\u0055\\u0056\\u0057\\u0059\\u005a\\u005c\\u0060\\u0061\\u0062\\u0063\\u0064\\u0065\\u0066\\u0067\\u0068\\u0069\\u006a\\u006b\\u006c\\u006d\\u006e\\u006f\\u0070\\u0071\\u0072\\u0073\\u0074\\u0075\\u0076\\u0077\\u0078\\u0079\\u007a\\u007b\\u007c\\u007d\\u007e]|[\\u0018\\u001a])|[\\u001b\\u0080\\u0081\\u0082\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c\\u009d\\u009e\\u009f](?=[\\u001b\\u0080\\u0081\\u0082\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c\\u009d\\u009e\\u009f]))"
|
|
482
482
|
}
|
package/src/ansi.mjs
CHANGED
|
@@ -86,28 +86,15 @@ export const SGR_RE = new RegExp(SGR_SOURCE, "g");
|
|
|
86
86
|
// scanner has already delimited.
|
|
87
87
|
const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// the
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
// ECMA-48 parameter bytes.
|
|
99
|
-
const CSI_PARAM_CLASS = "[0-9;:]";
|
|
100
|
-
const CSI_PARAM_RE = new RegExp(CSI_PARAM_CLASS);
|
|
101
|
-
|
|
102
|
-
// ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
|
|
103
|
-
// PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
|
|
104
|
-
// must not eat trailing visible digits (`ESC[2024 report` is NOT `ESC[` +
|
|
105
|
-
// final-byte `2`; it is an incomplete intro whose ESC the residual sweep
|
|
106
|
-
// removes, leaving "2024 report" intact). `<=>?` (0x3C-0x3F) are private
|
|
107
|
-
// PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
|
|
108
|
-
// private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
|
|
109
|
-
// final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
|
|
110
|
-
const CSI_FINAL_CLASS = "[A-PR-TZcf-nqrty~]";
|
|
89
|
+
// CSI as a terminal parses it (DEC's parser, vt100.net/emu/dec_ansi_parser):
|
|
90
|
+
// after `ESC [` or U+009B every byte in 0x20-0x3F is swallowed — ECMA-48's
|
|
91
|
+
// parameters (0x30-0x3F, private markers `<=>?` included) and intermediates
|
|
92
|
+
// (0x20-0x2F, e.g. the space in DECSCUSR `ESC[1 q`), well-formed or not — until
|
|
93
|
+
// one final byte (0x40-0x7E) ends the sequence. The two classes are disjoint, so
|
|
94
|
+
// the arm cannot backtrack.
|
|
95
|
+
const CSI_BODY_CLASS = "[ -?]";
|
|
96
|
+
const CSI_BODY_RE = new RegExp(CSI_BODY_CLASS);
|
|
97
|
+
const CSI_FINAL_CLASS = "[@-~]";
|
|
111
98
|
const CSI_FINAL_RE = new RegExp(CSI_FINAL_CLASS);
|
|
112
99
|
|
|
113
100
|
const ESC = 0x1b;
|
|
@@ -132,6 +119,11 @@ const SUB = 0x1a;
|
|
|
132
119
|
const LF = 0x0a;
|
|
133
120
|
const CR = 0x0d;
|
|
134
121
|
|
|
122
|
+
// CAN and SUB cancel a sequence in progress (ECMA-48; DEC's parser executes
|
|
123
|
+
// them and returns to ground), so they are consumed with the bytes they cancel.
|
|
124
|
+
const SEQUENCE_CANCEL = [CAN, SUB];
|
|
125
|
+
const INTRODUCER_RE = new RegExp(CONTROL_INTRODUCER_SOURCE);
|
|
126
|
+
|
|
135
127
|
// PROBLEM CLASS — a control string whose body the grammar leaves as visible
|
|
136
128
|
// text. ECMA-48 opens FIVE strings, not one: OSC (`ESC ]` / U+009D), DCS
|
|
137
129
|
// (`ESC P` / U+0090), SOS (`ESC X` / U+0098), PM (`ESC ^` / U+009E) and APC
|
|
@@ -142,8 +134,8 @@ const CR = 0x0d;
|
|
|
142
134
|
// prompt gate the two bare `ESC`s tokenized as inert orphans, which is the
|
|
143
135
|
// benign kind, so the gate answered `note` instead of `block`.
|
|
144
136
|
// The second byte of the 7-bit form, keyed by introducer. `P` also happens to
|
|
145
|
-
// be
|
|
146
|
-
// and the DCS body survived that way instead.
|
|
137
|
+
// be an escape final byte, so without this table `ESC P` completed a two-byte
|
|
138
|
+
// escape and the DCS body survived that way instead.
|
|
147
139
|
const STRING_INTRO_7BIT = "]PX^_";
|
|
148
140
|
const OSC_7BIT = "]";
|
|
149
141
|
const OSC_C1 = 0x9d;
|
|
@@ -152,14 +144,30 @@ const OSC_C1 = 0x9d;
|
|
|
152
144
|
// set: opening a string is exactly what ends the one already open.
|
|
153
145
|
const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
|
|
154
146
|
|
|
147
|
+
// Every other escape, per ECMA-35 § 13.1: `ESC`, intermediate bytes (0x20-0x2F),
|
|
148
|
+
// one final byte (0x30-0x7E) — charset designation `ESC(B`, DECALN `ESC#8`,
|
|
149
|
+
// DECSC `ESC 7`, keypad mode `ESC=`, RIS `ESC c`. A final DIRECTLY after the ESC
|
|
150
|
+
// excludes `[` and the string introducers: those open a CSI or control string
|
|
151
|
+
// instead, so an incomplete `ESC[` stays an orphan rather than a complete escape.
|
|
152
|
+
const ESC_INTERMEDIATE_CLASS = "[ -/]";
|
|
153
|
+
const ESC_INTERMEDIATE_RE = new RegExp(ESC_INTERMEDIATE_CLASS);
|
|
154
|
+
const ESC_FINAL_CLASS = "[0-~]";
|
|
155
|
+
const ESC_FINAL_RE = new RegExp(ESC_FINAL_CLASS);
|
|
156
|
+
const ESC_DIRECT_FINAL_CLASS = charClass(
|
|
157
|
+
Array.from({ length: 0x7e - 0x30 + 1 }, (_, i) => 0x30 + i).filter(
|
|
158
|
+
(cp) => !`[${STRING_INTRO_7BIT}`.includes(String.fromCharCode(cp)),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
const ESC_DIRECT_FINAL_RE = new RegExp(ESC_DIRECT_FINAL_CLASS);
|
|
162
|
+
|
|
155
163
|
/**
|
|
156
164
|
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
157
165
|
* artifact for a consumer that cannot run this module.
|
|
158
166
|
*
|
|
159
167
|
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
160
168
|
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
161
|
-
* it is linear by construction
|
|
162
|
-
*
|
|
169
|
+
* it is linear by construction. The regex stays linear too: in every arm each
|
|
170
|
+
* quantified class is disjoint from what follows it. What a regex CAN be is data —
|
|
163
171
|
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
164
172
|
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
165
173
|
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
@@ -192,17 +200,23 @@ export const ESCAPE_SEQUENCE_SOURCE = (() => {
|
|
|
192
200
|
`(?:${charClass(consumed)}|${escapeSt}` +
|
|
193
201
|
`|(?=${charClass(abortBefore)})|(?![\\s\\S]))`;
|
|
194
202
|
const stringArm = `(?:${introducer7Bit}|${charClass(c1Introducers)})${body}${terminator}`;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
// A sequence ends at its final byte, or is ABORTED (see scanCsi): CAN/SUB is
|
|
204
|
+
// consumed with it, another introducer ends it before that byte.
|
|
205
|
+
/** @param {string} finalClass */
|
|
206
|
+
const ending = (finalClass) =>
|
|
207
|
+
`(?:${finalClass}|${charClass(SEQUENCE_CANCEL)}|(?=${CONTROL_INTRODUCER_SOURCE}))`;
|
|
199
208
|
const csiArm =
|
|
200
|
-
|
|
201
|
-
`${
|
|
209
|
+
`(?:${unicodeEscape(ESC)}\\[|${unicodeEscape(CSI_C1)})` +
|
|
210
|
+
`${CSI_BODY_CLASS}*${ending(CSI_FINAL_CLASS)}`;
|
|
211
|
+
const escapeArm =
|
|
212
|
+
`${unicodeEscape(ESC)}(?:${ESC_INTERMEDIATE_CLASS}+${ending(ESC_FINAL_CLASS)}` +
|
|
213
|
+
`|${ESC_DIRECT_FINAL_CLASS}|${charClass(SEQUENCE_CANCEL)})`;
|
|
214
|
+
// isCancelledOrphan's introducer: one directly before another introducer.
|
|
215
|
+
const cancelledArm = `${CONTROL_INTRODUCER_SOURCE}(?=${CONTROL_INTRODUCER_SOURCE})`;
|
|
202
216
|
// The string arm runs FIRST for the same reason it does in scanAnsi: `P` is
|
|
203
|
-
// also
|
|
204
|
-
// leaves the DCS body as visible text.
|
|
205
|
-
return `(?:${stringArm}|${csiArm})`;
|
|
217
|
+
// also an escape final byte, so an escape-first alternation takes `ESC P`
|
|
218
|
+
// alone and leaves the DCS body as visible text.
|
|
219
|
+
return `(?:${stringArm}|${csiArm}|${escapeArm}|${cancelledArm})`;
|
|
206
220
|
})();
|
|
207
221
|
|
|
208
222
|
// The three SGR parameters that move the CONCEAL state (ECMA-48 § 8.3.117).
|
|
@@ -254,9 +268,9 @@ export const TOKEN_KIND = Object.freeze({
|
|
|
254
268
|
/**
|
|
255
269
|
* A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
|
|
256
270
|
* {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
271
|
+
* executes a line break and keeps waiting for a final byte (0x40-0x7E), so
|
|
272
|
+
* `hello ESC[12` + LF + `world` renders `orld` on the second line — the `w`
|
|
273
|
+
* is eaten as the sequence's final. That is the
|
|
260
274
|
* model-sees/human-sees divergence the gate exists for, so consumers that
|
|
261
275
|
* downgrade an inert strip to a note must keep warning on this one; only a
|
|
262
276
|
* lone `ESC` that opens nothing is inert.
|
|
@@ -279,7 +293,7 @@ export const TOKEN_KIND = Object.freeze({
|
|
|
279
293
|
/**
|
|
280
294
|
* True for either orphan kind — the tokens {@link scanAnsi} emits for an
|
|
281
295
|
* introducer that completes no sequence, which the stripper must leave in place
|
|
282
|
-
* for the residual sweep rather than splice (see
|
|
296
|
+
* for the residual sweep rather than splice (see stripAnsiFully).
|
|
283
297
|
* @param {string} kind one of {@link TOKEN_KIND}
|
|
284
298
|
* @returns {boolean}
|
|
285
299
|
*/
|
|
@@ -300,8 +314,8 @@ export function isOrphanKind(kind) {
|
|
|
300
314
|
* `[` is the only lookahead that matters. The five string introducers — `ESC ]`,
|
|
301
315
|
* `ESC P`, `ESC X`, `ESC ^`, `ESC _` — are consumed whole by
|
|
302
316
|
* {@link scanControlString}, to the end of input if unterminated, so they never
|
|
303
|
-
* reach here;
|
|
304
|
-
*
|
|
317
|
+
* reach here; any other incomplete escape (`ESC (` before a line break) costs a
|
|
318
|
+
* terminal at most intermediate bytes and one final byte, not a CSI's long run.
|
|
305
319
|
* @param {string} ch
|
|
306
320
|
* @param {string} [next] the following character, or undefined at end of input
|
|
307
321
|
* @returns {string}
|
|
@@ -378,27 +392,75 @@ function scanControlString(text, start) {
|
|
|
378
392
|
}
|
|
379
393
|
|
|
380
394
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
395
|
+
* Where a sequence whose body run stopped at `i` ends, or -1 when it is still
|
|
396
|
+
* incomplete there. A final byte (`finalRe`) or CAN/SUB ends it after that
|
|
397
|
+
* byte. Another control introducer ABORTS it before that byte: a terminal
|
|
398
|
+
* drops what it has swallowed so far and starts afresh, so the aborted bytes are
|
|
399
|
+
* one token and the introducer is re-read as its own.
|
|
400
|
+
* @param {string} text
|
|
401
|
+
* @param {number} i
|
|
402
|
+
* @param {RegExp} finalRe
|
|
403
|
+
* @returns {number}
|
|
404
|
+
*/
|
|
405
|
+
function sequenceEnd(text, i, finalRe) {
|
|
406
|
+
if (i >= text.length) return -1;
|
|
407
|
+
if (finalRe.test(text[i]) || SEQUENCE_CANCEL.includes(text.charCodeAt(i)))
|
|
408
|
+
return i + 1;
|
|
409
|
+
return INTRODUCER_RE.test(text[i]) ? i : -1;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* End index of the CSI sequence starting at `start`, or -1 when no complete or
|
|
414
|
+
* aborted CSI starts there. Greedy and single-pass over two disjoint classes.
|
|
390
415
|
* @param {string} text
|
|
391
416
|
* @param {number} start
|
|
392
417
|
* @returns {number}
|
|
393
418
|
*/
|
|
394
419
|
function scanCsi(text, start) {
|
|
395
420
|
const code = text.charCodeAt(start);
|
|
396
|
-
|
|
421
|
+
let i;
|
|
422
|
+
if (code === CSI_C1) i = start + 1;
|
|
423
|
+
else if (code === ESC && text[start + 1] === "[") i = start + 2;
|
|
424
|
+
else return -1;
|
|
425
|
+
while (i < text.length && CSI_BODY_RE.test(text[i])) i++;
|
|
426
|
+
return sequenceEnd(text, i, CSI_FINAL_RE);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* End index of the non-CSI escape (`ESC(B`, `ESC#8`, `ESC 7`) starting at
|
|
431
|
+
* `start`, or -1 when no complete or aborted escape starts there. A bare `ESC`
|
|
432
|
+
* directly before another introducer is not one: see {@link isCancelledOrphan}.
|
|
433
|
+
* @param {string} text
|
|
434
|
+
* @param {number} start
|
|
435
|
+
* @returns {number}
|
|
436
|
+
*/
|
|
437
|
+
function scanEscape(text, start) {
|
|
438
|
+
if (text.charCodeAt(start) !== ESC) return -1;
|
|
397
439
|
let i = start + 1;
|
|
398
|
-
while (i < text.length &&
|
|
399
|
-
|
|
400
|
-
if (i
|
|
401
|
-
|
|
440
|
+
while (i < text.length && ESC_INTERMEDIATE_RE.test(text[i])) i++;
|
|
441
|
+
if (i > start + 1) return sequenceEnd(text, i, ESC_FINAL_RE);
|
|
442
|
+
if (i >= text.length) return -1;
|
|
443
|
+
const directEnd =
|
|
444
|
+
ESC_DIRECT_FINAL_RE.test(text[i]) ||
|
|
445
|
+
SEQUENCE_CANCEL.includes(text.charCodeAt(i));
|
|
446
|
+
return directEnd ? i + 1 : -1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* True for an orphan a terminal CANCELS: the next character is another control
|
|
451
|
+
* introducer, which starts afresh, so this one can never complete. A strip
|
|
452
|
+
* splices it at once — left in place, removing the sequence after it would join
|
|
453
|
+
* it to the text beyond, a sequence no terminal ever parses.
|
|
454
|
+
* @param {string} text
|
|
455
|
+
* @param {AnsiToken} token a token {@link scanAnsi} returned for `text`
|
|
456
|
+
* @returns {boolean}
|
|
457
|
+
*/
|
|
458
|
+
export function isCancelledOrphan(text, token) {
|
|
459
|
+
return (
|
|
460
|
+
isOrphanKind(token.kind) &&
|
|
461
|
+
token.end < text.length &&
|
|
462
|
+
INTRODUCER_RE.test(text[token.end])
|
|
463
|
+
);
|
|
402
464
|
}
|
|
403
465
|
|
|
404
466
|
// Drives the scan: jumping introducer-to-introducer keeps the common case (text
|
|
@@ -428,11 +490,12 @@ export function scanAnsi(text) {
|
|
|
428
490
|
let match;
|
|
429
491
|
while ((match = INTRODUCER_SCAN_RE.exec(text)) !== null) {
|
|
430
492
|
const start = match.index;
|
|
431
|
-
// The string arms run FIRST: `ESC P` also completes a two-byte
|
|
432
|
-
// final byte), so letting
|
|
493
|
+
// The string arms run FIRST: `ESC P` also completes a two-byte escape (`P`
|
|
494
|
+
// is a final byte), so letting scanEscape answer first would take the DCS
|
|
433
495
|
// introducer alone and leave its body as visible text.
|
|
434
496
|
const string = scanControlString(text, start);
|
|
435
|
-
|
|
497
|
+
let csiEnd = string ? -1 : scanCsi(text, start);
|
|
498
|
+
if (!string && csiEnd < 0) csiEnd = scanEscape(text, start);
|
|
436
499
|
let end = start + 1;
|
|
437
500
|
/** @type {string} */
|
|
438
501
|
let kind = orphanKindFor(text[start], text[start + 1]);
|
package/src/layer1.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { stripInvisibleWithReport, CATEGORY, STRIP } from "./invisible.mjs";
|
|
25
25
|
import {
|
|
26
26
|
CONTROL_INTRODUCER_SOURCE,
|
|
27
|
+
isCancelledOrphan,
|
|
27
28
|
isOrphanKind,
|
|
28
29
|
orphanKindFor,
|
|
29
30
|
scanAnsi,
|
|
@@ -63,60 +64,34 @@ function sweepIntroducers(text, kinds) {
|
|
|
63
64
|
export const LONE_SURROGATE_RE =
|
|
64
65
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
65
66
|
|
|
66
|
-
const MAX_ANSI_PASSES = 3;
|
|
67
|
-
|
|
68
67
|
/**
|
|
69
68
|
* Splice out every complete ANSI sequence {@link scanAnsi} finds — SGR, other
|
|
70
|
-
* CSI/
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
69
|
+
* CSI/escapes, aborted sequences and whole control strings — in ONE pass, which
|
|
70
|
+
* is already a fixed point: a token ends only where a terminal's parser would,
|
|
71
|
+
* and a cancelled orphan (see {@link isCancelledOrphan}) goes with it, so no
|
|
72
|
+
* removal can join what is left into a new sequence. Any other ORPHAN
|
|
73
|
+
* introducer is deliberately LEFT: deleting it here would drop the `ESC` out of
|
|
74
|
+
* `ESC`<ZWSP>`[32m` before the invisible pass could reconstitute the sequence,
|
|
75
|
+
* turning a hidden control into visible `[32m` in the model's view. Orphans are
|
|
76
|
+
* removed by applyLayer1's residual sweep, once no reconstitution is possible.
|
|
77
|
+
* @param {string} input
|
|
77
78
|
* @param {Set<string>} [kinds] collects the {@link TOKEN_KIND} of every
|
|
78
|
-
* sequence
|
|
79
|
-
*
|
|
79
|
+
* sequence actually removed, so a caller can tell a display-only colour strip
|
|
80
|
+
* from a cursor/erase/OSC one without re-scanning (see
|
|
80
81
|
* {@link isBenignAnsiKinds}).
|
|
81
82
|
* @returns {string}
|
|
82
83
|
*/
|
|
83
|
-
function
|
|
84
|
+
export function stripAnsiFully(input, kinds) {
|
|
84
85
|
let out = "";
|
|
85
86
|
let last = 0;
|
|
86
|
-
for (const token of scanAnsi(
|
|
87
|
-
if (isOrphanKind(token.kind)) continue;
|
|
87
|
+
for (const token of scanAnsi(input)) {
|
|
88
|
+
if (isOrphanKind(token.kind) && !isCancelledOrphan(input, token)) continue;
|
|
88
89
|
kinds?.add(token.kind);
|
|
89
|
-
out +=
|
|
90
|
+
out += input.slice(last, token.start);
|
|
90
91
|
last = token.end;
|
|
91
92
|
}
|
|
92
|
-
if (last === 0) return
|
|
93
|
-
return out +
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Strip ANSI escape sequences to a fixed point. Removing one sequence can
|
|
98
|
-
* reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
|
|
99
|
-
* trailing `[0m` once the inner sequence is removed, forming a brand-new valid
|
|
100
|
-
* sequence the single pass would miss), so iterate — but only a fixed few
|
|
101
|
-
* times. Bounding by the input's ESC count is quadratic on attacker-controlled
|
|
102
|
-
* text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
|
|
103
|
-
* sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
|
|
104
|
-
* The passes are not what makes Layer 1 safe — applyLayer1's residual
|
|
105
|
-
* CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
|
|
106
|
-
* survives here. Past the bound a reconstituted sequence therefore degrades to
|
|
107
|
-
* VISIBLE text rather than a hidden control, which is the fail-open direction.
|
|
108
|
-
* @param {string} input
|
|
109
|
-
* @param {Set<string>} [kinds] see {@link stripAnsiOnce}; accumulates across passes
|
|
110
|
-
* @returns {string}
|
|
111
|
-
*/
|
|
112
|
-
export function stripAnsiFully(input, kinds) {
|
|
113
|
-
let prev = input;
|
|
114
|
-
let out = stripAnsiOnce(prev, kinds);
|
|
115
|
-
for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
|
|
116
|
-
prev = out;
|
|
117
|
-
out = stripAnsiOnce(prev, kinds);
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
93
|
+
if (last === 0) return input;
|
|
94
|
+
return out + input.slice(last);
|
|
120
95
|
}
|
|
121
96
|
|
|
122
97
|
/**
|
|
@@ -260,8 +235,8 @@ export function isBenignAnsi(text) {
|
|
|
260
235
|
|
|
261
236
|
// How many times the {ANSI strip, invisible strip} composition may re-run before
|
|
262
237
|
// the sweep is forced. Each iteration deletes at least one character, so the
|
|
263
|
-
// loop terminates on its own; the bound is a DoS guard
|
|
264
|
-
//
|
|
238
|
+
// loop terminates on its own; the bound is a DoS guard: n rounds of O(n) scans
|
|
239
|
+
// on adversarial input is quadratic.
|
|
265
240
|
// Three rounds is what the deepest REAL reconstitution needs — strip
|
|
266
241
|
// invisibles, remove the ANSI they hid, strip the joiners that ANSI removal
|
|
267
242
|
// made adjacent, confirm — so four leaves a round of margin. Deeper nesting is
|
|
@@ -270,7 +245,7 @@ export function isBenignAnsi(text) {
|
|
|
270
245
|
// escape sequence, and every joiner that hides one is therefore stripped by the
|
|
271
246
|
// FIRST invisible pass. Past the bound the composition simply stops early — the
|
|
272
247
|
// final sweep still holds the no-introducer guarantee, and what is left degrades
|
|
273
|
-
// to visible text (the fail-open direction)
|
|
248
|
+
// to visible text (the fail-open direction).
|
|
274
249
|
const MAX_LAYER1_PASSES = 4;
|
|
275
250
|
|
|
276
251
|
/**
|
package/types/ansi.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* True for either orphan kind — the tokens {@link scanAnsi} emits for an
|
|
3
3
|
* introducer that completes no sequence, which the stripper must leave in place
|
|
4
|
-
* for the residual sweep rather than splice (see
|
|
4
|
+
* for the residual sweep rather than splice (see stripAnsiFully).
|
|
5
5
|
* @param {string} kind one of {@link TOKEN_KIND}
|
|
6
6
|
* @returns {boolean}
|
|
7
7
|
*/
|
|
@@ -15,13 +15,23 @@ export function isOrphanKind(kind: string): boolean;
|
|
|
15
15
|
* `[` is the only lookahead that matters. The five string introducers — `ESC ]`,
|
|
16
16
|
* `ESC P`, `ESC X`, `ESC ^`, `ESC _` — are consumed whole by
|
|
17
17
|
* {@link scanControlString}, to the end of input if unterminated, so they never
|
|
18
|
-
* reach here;
|
|
19
|
-
*
|
|
18
|
+
* reach here; any other incomplete escape (`ESC (` before a line break) costs a
|
|
19
|
+
* terminal at most intermediate bytes and one final byte, not a CSI's long run.
|
|
20
20
|
* @param {string} ch
|
|
21
21
|
* @param {string} [next] the following character, or undefined at end of input
|
|
22
22
|
* @returns {string}
|
|
23
23
|
*/
|
|
24
24
|
export function orphanKindFor(ch: string, next?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* True for an orphan a terminal CANCELS: the next character is another control
|
|
27
|
+
* introducer, which starts afresh, so this one can never complete. A strip
|
|
28
|
+
* splices it at once — left in place, removing the sequence after it would join
|
|
29
|
+
* it to the text beyond, a sequence no terminal ever parses.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @param {AnsiToken} token a token {@link scanAnsi} returned for `text`
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isCancelledOrphan(text: string, token: AnsiToken): boolean;
|
|
25
35
|
/**
|
|
26
36
|
* Tokenize every raw control introducer in `text`.
|
|
27
37
|
*
|
|
@@ -100,8 +110,8 @@ export const SGR_RE: RegExp;
|
|
|
100
110
|
*
|
|
101
111
|
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
102
112
|
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
103
|
-
* it is linear by construction
|
|
104
|
-
*
|
|
113
|
+
* it is linear by construction. The regex stays linear too: in every arm each
|
|
114
|
+
* quantified class is disjoint from what follows it. What a regex CAN be is data —
|
|
105
115
|
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
106
116
|
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
107
117
|
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
@@ -139,9 +149,9 @@ export const TOKEN_KIND: Readonly<{
|
|
|
139
149
|
/**
|
|
140
150
|
* A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
|
|
141
151
|
* {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
152
|
+
* executes a line break and keeps waiting for a final byte (0x40-0x7E), so
|
|
153
|
+
* `hello ESC[12` + LF + `world` renders `orld` on the second line — the `w`
|
|
154
|
+
* is eaten as the sequence's final. That is the
|
|
145
155
|
* model-sees/human-sees divergence the gate exists for, so consumers that
|
|
146
156
|
* downgrade an inert strip to a note must keep warning on this one; only a
|
|
147
157
|
* lone `ESC` that opens nothing is inert.
|
package/types/layer1.d.mts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* survives here. Past the bound a reconstituted sequence therefore degrades to
|
|
12
|
-
* VISIBLE text rather than a hidden control, which is the fail-open direction.
|
|
2
|
+
* Splice out every complete ANSI sequence {@link scanAnsi} finds — SGR, other
|
|
3
|
+
* CSI/escapes, aborted sequences and whole control strings — in ONE pass, which
|
|
4
|
+
* is already a fixed point: a token ends only where a terminal's parser would,
|
|
5
|
+
* and a cancelled orphan (see {@link isCancelledOrphan}) goes with it, so no
|
|
6
|
+
* removal can join what is left into a new sequence. Any other ORPHAN
|
|
7
|
+
* introducer is deliberately LEFT: deleting it here would drop the `ESC` out of
|
|
8
|
+
* `ESC`<ZWSP>`[32m` before the invisible pass could reconstitute the sequence,
|
|
9
|
+
* turning a hidden control into visible `[32m` in the model's view. Orphans are
|
|
10
|
+
* removed by applyLayer1's residual sweep, once no reconstitution is possible.
|
|
13
11
|
* @param {string} input
|
|
14
|
-
* @param {Set<string>} [kinds]
|
|
12
|
+
* @param {Set<string>} [kinds] collects the {@link TOKEN_KIND} of every
|
|
13
|
+
* sequence actually removed, so a caller can tell a display-only colour strip
|
|
14
|
+
* from a cursor/erase/OSC one without re-scanning (see
|
|
15
|
+
* {@link isBenignAnsiKinds}).
|
|
15
16
|
* @returns {string}
|
|
16
17
|
*/
|
|
17
18
|
export function stripAnsiFully(input: string, kinds?: Set<string>): string;
|
package/types/src/ansi.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* True for either orphan kind — the tokens {@link scanAnsi} emits for an
|
|
3
3
|
* introducer that completes no sequence, which the stripper must leave in place
|
|
4
|
-
* for the residual sweep rather than splice (see
|
|
4
|
+
* for the residual sweep rather than splice (see stripAnsiFully).
|
|
5
5
|
* @param {string} kind one of {@link TOKEN_KIND}
|
|
6
6
|
* @returns {boolean}
|
|
7
7
|
*/
|
|
@@ -15,13 +15,23 @@ export function isOrphanKind(kind: string): boolean;
|
|
|
15
15
|
* `[` is the only lookahead that matters. The five string introducers — `ESC ]`,
|
|
16
16
|
* `ESC P`, `ESC X`, `ESC ^`, `ESC _` — are consumed whole by
|
|
17
17
|
* {@link scanControlString}, to the end of input if unterminated, so they never
|
|
18
|
-
* reach here;
|
|
19
|
-
*
|
|
18
|
+
* reach here; any other incomplete escape (`ESC (` before a line break) costs a
|
|
19
|
+
* terminal at most intermediate bytes and one final byte, not a CSI's long run.
|
|
20
20
|
* @param {string} ch
|
|
21
21
|
* @param {string} [next] the following character, or undefined at end of input
|
|
22
22
|
* @returns {string}
|
|
23
23
|
*/
|
|
24
24
|
export function orphanKindFor(ch: string, next?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* True for an orphan a terminal CANCELS: the next character is another control
|
|
27
|
+
* introducer, which starts afresh, so this one can never complete. A strip
|
|
28
|
+
* splices it at once — left in place, removing the sequence after it would join
|
|
29
|
+
* it to the text beyond, a sequence no terminal ever parses.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @param {AnsiToken} token a token {@link scanAnsi} returned for `text`
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isCancelledOrphan(text: string, token: AnsiToken): boolean;
|
|
25
35
|
/**
|
|
26
36
|
* Tokenize every raw control introducer in `text`.
|
|
27
37
|
*
|
|
@@ -100,8 +110,8 @@ export const SGR_RE: RegExp;
|
|
|
100
110
|
*
|
|
101
111
|
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
102
112
|
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
103
|
-
* it is linear by construction
|
|
104
|
-
*
|
|
113
|
+
* it is linear by construction. The regex stays linear too: in every arm each
|
|
114
|
+
* quantified class is disjoint from what follows it. What a regex CAN be is data —
|
|
105
115
|
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
106
116
|
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
107
117
|
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
@@ -139,9 +149,9 @@ export const TOKEN_KIND: Readonly<{
|
|
|
139
149
|
/**
|
|
140
150
|
* A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
|
|
141
151
|
* {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
152
|
+
* executes a line break and keeps waiting for a final byte (0x40-0x7E), so
|
|
153
|
+
* `hello ESC[12` + LF + `world` renders `orld` on the second line — the `w`
|
|
154
|
+
* is eaten as the sequence's final. That is the
|
|
145
155
|
* model-sees/human-sees divergence the gate exists for, so consumers that
|
|
146
156
|
* downgrade an inert strip to a note must keep warning on this one; only a
|
|
147
157
|
* lone `ESC` that opens nothing is inert.
|