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 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 one ANSI sequence can reconstitute another, and removing an
58
- ANSI sequence can make two invisibles adjacent that were not—a joiner run the
59
- invisible pass treats as a payload channel rather than as linguistic. So Layer 1
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.8",
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]))|[\\u001b\\u009b][\\[()#;?]*(?![\\[()#;?])[0-9;:]*[A-PR-TZcf-nqrty~])"
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
- // Private parameter-prefix and intermediate bytes that may follow an
90
- // introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
91
- // the 7-bit `ESC [` CSI introducer's bracket itself.
92
- // The `[` is escaped though neither engine requires it: Python's `re` warns
93
- // `FutureWarning: Possible nested set` on a bare one, and this class ships as
94
- // the generated pattern a Python consumer compiles.
95
- const CSI_INTRO_CLASS = "[\\[()#;?]";
96
- const CSI_INTRO_RE = new RegExp(CSI_INTRO_CLASS);
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 a CSI final byte, so without this table `ESC P` completed a two-byte CSI
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 where the regex form has to carry an explicit
162
- * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
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
- // The negative lookahead pins the intro run MAXIMAL — which is what the
196
- // scanner's `while` loop does — and in doing so removes the only place the
197
- // two quantifiers could repartition (`;` is in both classes), so this cannot
198
- // backtrack super-linearly the way an unbounded `[…;…]*[…;…]*` would.
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
- `${charClass([ESC, CSI_C1])}${CSI_INTRO_CLASS}*(?!${CSI_INTRO_CLASS})` +
201
- `${CSI_PARAM_CLASS}*${CSI_FINAL_CLASS}`;
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 a CSI final byte, so a CSI-first alternation takes `ESC P` alone and
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
- * keeps consuming what follows as parameters and intermediates until a final
258
- * byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
259
- * — the ` w` is eaten as the sequence's intermediate and final. That is the
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 stripAnsiOnce).
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; every other second byte (`ESC (`, `ESC #`) bounds what a terminal
304
- * swallows to a byte or two rather than running until a final byte arrives.
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
- * End index of the CSI / two-byte escape sequence starting at `start`, or -1
382
- * when the introducer completes no sequence.
383
- *
384
- * Single-pass and greedy: intro bytes, then parameter bytes, then exactly one
385
- * final byte. The previous regex form had to BOUND the intro run ({0,12})
386
- * because `;` lives in both the intro and parameter classes, so an unbounded
387
- * run let a `;#;#…` string be split between the two quantifiers — quadratic
388
- * backtracking (CodeQL js/polynomial-redos). A hand-written scanner never
389
- * backtracks, so the bound is gone and the scan is linear by construction.
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
- if (code !== ESC && code !== CSI_C1) return -1;
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 && CSI_INTRO_RE.test(text[i])) i++;
399
- while (i < text.length && CSI_PARAM_RE.test(text[i])) i++;
400
- if (i < text.length && CSI_FINAL_RE.test(text[i])) return i + 1;
401
- return -1;
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 CSI (`P` is a
432
- // final byte), so letting scanCsi answer first would take the DCS
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
- const csiEnd = string ? -1 : scanCsi(text, start);
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/two-byte escapes, and whole OSC strings. An ORPHAN introducer (one that
71
- * starts no sequence) is deliberately LEFT: deleting it here would drop the
72
- * `ESC` out of `ESC`<ZWSP>`[32m` before the invisible pass could reconstitute
73
- * the sequence, turning a hidden control into visible `[32m` in the model's
74
- * view. Orphans are removed by applyLayer1's residual sweep, once no
75
- * reconstitution is possible.
76
- * @param {string} text
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 this pass actually removed, so a caller can tell a display-only
79
- * colour strip from a cursor/erase/OSC one without re-scanning (see
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 stripAnsiOnce(text, kinds) {
84
+ export function stripAnsiFully(input, kinds) {
84
85
  let out = "";
85
86
  let last = 0;
86
- for (const token of scanAnsi(text)) {
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 += text.slice(last, token.start);
90
+ out += input.slice(last, token.start);
90
91
  last = token.end;
91
92
  }
92
- if (last === 0) return text;
93
- return out + text.slice(last);
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 on the same quadratic
264
- // argument as MAX_ANSI_PASSES (n rounds of O(n) scans on adversarial input).
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), exactly as with MAX_ANSI_PASSES.
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 stripAnsiOnce).
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; every other second byte (`ESC (`, `ESC #`) bounds what a terminal
19
- * swallows to a byte or two rather than running until a final byte arrives.
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 where the regex form has to carry an explicit
104
- * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
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
- * keeps consuming what follows as parameters and intermediates until a final
143
- * byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
144
- * — the ` w` is eaten as the sequence's intermediate and final. That is the
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.
@@ -1,17 +1,18 @@
1
1
  /**
2
- * Strip ANSI escape sequences to a fixed point. Removing one sequence can
3
- * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
4
- * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
5
- * sequence the single pass would miss), so iterate — but only a fixed few
6
- * times. Bounding by the input's ESC count is quadratic on attacker-controlled
7
- * text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
8
- * sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
9
- * The passes are not what makes Layer 1 safe — applyLayer1's residual
10
- * CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
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] see {@link stripAnsiOnce}; accumulates across passes
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;
@@ -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 stripAnsiOnce).
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; every other second byte (`ESC (`, `ESC #`) bounds what a terminal
19
- * swallows to a byte or two rather than running until a final byte arrives.
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 where the regex form has to carry an explicit
104
- * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
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
- * keeps consuming what follows as parameters and intermediates until a final
143
- * byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
144
- * — the ` w` is eaten as the sequence's intermediate and final. That is the
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.