agent-sanitizer 2.37.0 → 2.37.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.37.0",
3
+ "version": "2.37.1",
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
2
+ "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
3
3
  "unicode_version": "17.0",
4
4
  "extra_codepoints": [
5
5
  847,
@@ -477,5 +477,6 @@
477
477
  157,
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
482
  }
package/src/ansi.mjs CHANGED
@@ -43,9 +43,25 @@ export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
43
43
  // grep-based drift check as well. Derived from the code-point list above so the
44
44
  // regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
45
45
  // control byte out of the source (no `no-control-regex` disable needed).
46
- export const CONTROL_INTRODUCER_SOURCE = `[${CONTROL_INTRODUCER_CODEPOINTS.map(
47
- (cp) => `\\u${cp.toString(16).padStart(4, "0")}`,
48
- ).join("")}]`;
46
+ export const CONTROL_INTRODUCER_SOURCE = charClass(
47
+ CONTROL_INTRODUCER_CODEPOINTS,
48
+ );
49
+
50
+ /** A code point as a `\uXXXX` escape — the one spelling of a control byte that
51
+ * both this module's regexes and the generated Python pattern use, so no raw
52
+ * control byte ever lands in either source.
53
+ * @param {number} cp
54
+ * @returns {string} */
55
+ function unicodeEscape(cp) {
56
+ return `\\u${cp.toString(16).padStart(4, "0")}`;
57
+ }
58
+
59
+ /** A character class matching exactly the given code points.
60
+ * @param {readonly number[]} cps
61
+ * @returns {string} */
62
+ function charClass(cps) {
63
+ return `[${cps.map(unicodeEscape).join("")}]`;
64
+ }
49
65
 
50
66
  // SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
51
67
  // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
@@ -73,10 +89,15 @@ const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
73
89
  // Private parameter-prefix and intermediate bytes that may follow an
74
90
  // introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
75
91
  // the 7-bit `ESC [` CSI introducer's bracket itself.
76
- const CSI_INTRO_RE = /[[()#;?]/;
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);
77
97
 
78
98
  // ECMA-48 parameter bytes.
79
- const CSI_PARAM_RE = /[0-9;:]/;
99
+ const CSI_PARAM_CLASS = "[0-9;:]";
100
+ const CSI_PARAM_RE = new RegExp(CSI_PARAM_CLASS);
80
101
 
81
102
  // ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
82
103
  // PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
@@ -86,15 +107,26 @@ const CSI_PARAM_RE = /[0-9;:]/;
86
107
  // PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
87
108
  // private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
88
109
  // final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
89
- const CSI_FINAL_RE = /[A-PR-TZcf-nqrty~]/;
110
+ const CSI_FINAL_CLASS = "[A-PR-TZcf-nqrty~]";
111
+ const CSI_FINAL_RE = new RegExp(CSI_FINAL_CLASS);
90
112
 
91
113
  const ESC = 0x1b;
92
114
  const CSI_C1 = 0x9b;
93
115
  const ST_C1 = 0x9c;
94
116
  const BEL = 0x07;
95
- // CAN/SUB cancel a control string per ECMA-48 and the xterm parser; LF/CR do
96
- // not, but bound the body anyway as a fail-closed blast-radius limit (see
97
- // scanControlString).
117
+ // THE ABORT SET the four controls that end a control string short of its
118
+ // terminator, and the whole of it. Every other C0 control and DEL is
119
+ // deliberately consumed as body, because that is what a terminal does with
120
+ // them: DEC's parser (vt100.net/emu/dec_ansi_parser) IGNORES C0 other than
121
+ // CAN/SUB/ESC in `osc_string` and `sos_pm_apc_string` and `put`s them in
122
+ // `dcs_passthrough`, so aborting on `VT`/`FF`/`NUL`/`DEL` would end the token
123
+ // early and splice the rest of a payload the terminal swallows back into the
124
+ // model's view — the under-strip this layer exists to close.
125
+ // CAN/SUB — ECMA-48 and that same parser cancel the string here.
126
+ // LF/CR — NOT terminal behavior, a fail-closed blast-radius limit: they
127
+ // are the only two controls that cross a line, and a body running
128
+ // past one blinds a reader who consumes the strip as a RECORD
129
+ // rather than rendering it (see scanControlString).
98
130
  const CAN = 0x18;
99
131
  const SUB = 0x1a;
100
132
  const LF = 0x0a;
@@ -120,6 +152,59 @@ const OSC_C1 = 0x9d;
120
152
  // set: opening a string is exactly what ends the one already open.
121
153
  const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
122
154
 
155
+ /**
156
+ * The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
157
+ * artifact for a consumer that cannot run this module.
158
+ *
159
+ * The scanner below is AUTHORITATIVE and this is derived from its own constants,
160
+ * 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 —
163
+ * a stdlib-only Python filter on an uncontrolled host, with no install path for
164
+ * this package, can read a pattern string but cannot import a tokenizer. So the
165
+ * generator pins this into `data/invisible-charset.json` beside the introducer
166
+ * set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
167
+ * hand-written spellings of one grammar.
168
+ *
169
+ * Every construct here is common to JS and Python `re` with NO flags —
170
+ * `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
171
+ * `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
172
+ * — so ONE pattern string is what both engines read.
173
+ * `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
174
+ * corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
175
+ */
176
+ export const ESCAPE_SEQUENCE_SOURCE = (() => {
177
+ const introducer7Bit = `${unicodeEscape(ESC)}${charClass(
178
+ [...STRING_INTRO_7BIT].map((ch) => ch.charCodeAt(0)),
179
+ )}`;
180
+ const c1Introducers = [...STRING_INTRO_C1].sort((a, b) => a - b);
181
+ // Consumed with the body, vs the bytes the token ends BEFORE (zero-width) so
182
+ // the scan re-reads them — the split scanControlString makes byte for byte.
183
+ const consumed = [BEL, CAN, SUB, ST_C1].sort((a, b) => a - b);
184
+ const abortBefore = [ESC, ...c1Introducers, LF, CR].sort((a, b) => a - b);
185
+ const body = `[^${[...new Set([...consumed, ...abortBefore])]
186
+ .sort((a, b) => a - b)
187
+ .map(unicodeEscape)
188
+ .join("")}]*`;
189
+ // `ESC \` is the 7-bit ST, the one two-byte terminator.
190
+ const escapeSt = `${unicodeEscape(ESC)}${unicodeEscape(0x5c)}`;
191
+ const terminator =
192
+ `(?:${charClass(consumed)}|${escapeSt}` +
193
+ `|(?=${charClass(abortBefore)})|(?![\\s\\S]))`;
194
+ 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.
199
+ const csiArm =
200
+ `${charClass([ESC, CSI_C1])}${CSI_INTRO_CLASS}*(?!${CSI_INTRO_CLASS})` +
201
+ `${CSI_PARAM_CLASS}*${CSI_FINAL_CLASS}`;
202
+ // 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})`;
206
+ })();
207
+
123
208
  /** The seven things an introducer can turn out to be. */
124
209
  export const TOKEN_KIND = Object.freeze({
125
210
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
@@ -230,7 +315,9 @@ export function orphanKindFor(ch, next) {
230
315
  * bound one stray `ESC ]` deleted every later line to end of input, so on a
231
316
  * consumer that reads the strip as a RECORD (a model, not a display) one
232
317
  * introducer blinded the whole tail behind a clean-looking prefix. The
233
- * break survives; the payload after it on the same line is dropped.
318
+ * break survives; the payload after it on the same line is dropped. This is
319
+ * what makes the layer-wide invariant hold — no token of any kind spans a
320
+ * line break, so a strip NEVER removes a newline (test/layer1-ansi).
234
321
  * 4. end of input, for a genuinely unterminated string with no line break:
235
322
  * fail closed and drop everything from the introducer on, so no body
236
323
  * survives.
package/types/ansi.d.mts CHANGED
@@ -65,6 +65,28 @@ export const CONTROL_INTRODUCER_SOURCE: string;
65
65
  * and the regex can no longer describe different languages.
66
66
  */
67
67
  export const SGR_RE: RegExp;
68
+ /**
69
+ * The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
70
+ * artifact for a consumer that cannot run this module.
71
+ *
72
+ * The scanner below is AUTHORITATIVE and this is derived from its own constants,
73
+ * never the other way round: the scanner emits token KINDS a regex cannot, and
74
+ * it is linear by construction where the regex form has to carry an explicit
75
+ * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
76
+ * a stdlib-only Python filter on an uncontrolled host, with no install path for
77
+ * this package, can read a pattern string but cannot import a tokenizer. So the
78
+ * generator pins this into `data/invisible-charset.json` beside the introducer
79
+ * set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
80
+ * hand-written spellings of one grammar.
81
+ *
82
+ * Every construct here is common to JS and Python `re` with NO flags —
83
+ * `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
84
+ * `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
85
+ * — so ONE pattern string is what both engines read.
86
+ * `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
87
+ * corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
88
+ */
89
+ export const ESCAPE_SEQUENCE_SOURCE: string;
68
90
  /** The seven things an introducer can turn out to be. */
69
91
  export const TOKEN_KIND: Readonly<{
70
92
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */