agent-sanitizer 2.31.5 → 2.31.6

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
@@ -78,12 +78,14 @@ introducers) keeps the WARNING. An `ESC` that _opened_ a CSI it never completed
78
78
  stays loud too: a terminal's CSI parser is stateful and keeps consuming what
79
79
  follows until a final byte arrives, so `ESC[12 world` shows the human `orld`
80
80
  while the model reads every word—the same model-sees/human-sees divergence a
81
- complete sequence buys. OSC strings (titles,
82
- clickable-hyperlink URLs) are consumed as a
81
+ complete sequence buys. All five ECMA-48 control strings—OSC (titles,
82
+ clickable-hyperlink URLs), DCS, SOS, PM and APC—are consumed as a
83
83
  whole, for every terminator form—ST (`ESC\` or 8-bit C1 ST U+009C) and the
84
- legacy BEL—and for the 8-bit C1 OSC introducer (U+009D); an _unterminated_ OSC
85
- introducer is dropped through end-of-string (fail-closed), so no OSC body
86
- survives to carry a payload.
84
+ legacy BEL—and for the 8-bit C1 introducers (U+0090/0098/009D/009E/009F); an
85
+ _unterminated_ introducer is dropped through end-of-string (fail-closed), so no
86
+ string body survives to carry a payload. Every one of those bodies is
87
+ attacker-controlled text, which is why the introducer alone is not enough to
88
+ remove.
87
89
 
88
90
  ## Layer 2—hidden HTML (remark/rehype)
89
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.31.5",
3
+ "version": "2.31.6",
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": {
package/src/ansi.mjs CHANGED
@@ -21,9 +21,9 @@
21
21
  // the entire 8-bit C1 control block (U+0080-U+009F) — which includes CSI
22
22
  // (U+009B), the string introducers DCS/SOS/OSC/PM/APC
23
23
  // (U+0090/0098/009D/009E/009F), and ST (U+009C). Gating the whole C1 block, not
24
- // just the introducers the sequence grammar below names, fails closed: a
25
- // DCS/SOS/PM/APC string the grammar does not consume still loses its introducer
26
- // and terminator, so no terminal can hide-render its body as a control payload.
24
+ // just the introducers the sequence grammar below names, fails closed: a C1 byte
25
+ // the grammar recognizes no sequence for still loses that byte, so no terminal
26
+ // can hide-render what follows it as a control payload.
27
27
  //
28
28
  // The introducer set as DATA, so a non-JS consumer can share it: the generator
29
29
  // (scripts/gen-invisible-charset.mjs) pins these code points into
@@ -91,10 +91,29 @@ const CSI_FINAL_RE = /[A-PR-TZcf-nqrty~]/;
91
91
  const ESC = 0x1b;
92
92
  const CSI_C1 = 0x9b;
93
93
  const ST_C1 = 0x9c;
94
- const OSC_C1 = 0x9d;
95
94
  const BEL = 0x07;
96
95
 
97
- /** The six things an introducer can turn out to be. */
96
+ // PROBLEM CLASS a control string whose body the grammar leaves as visible
97
+ // text. ECMA-48 opens FIVE strings, not one: OSC (`ESC ]` / U+009D), DCS
98
+ // (`ESC P` / U+0090), SOS (`ESC X` / U+0098), PM (`ESC ^` / U+009E) and APC
99
+ // (`ESC _` / U+009F). All five share one grammar — introducer, body, ST — and
100
+ // every body is attacker-controlled payload text, so all five are consumed as
101
+ // one token. Recognizing OSC alone left the other four's bodies in the model's
102
+ // view: `ESC _ hidden ESC \` reduced to the visible text `_hidden\`, and at the
103
+ // prompt gate the two bare `ESC`s tokenized as inert orphans, which is the
104
+ // benign kind, so the gate answered `note` instead of `block`.
105
+ // The second byte of the 7-bit form, keyed by introducer. `P` also happens to
106
+ // be a CSI final byte, so without this table `ESC P` completed a two-byte CSI
107
+ // and the DCS body survived that way instead.
108
+ const STRING_INTRO_7BIT = "]PX^_";
109
+ const OSC_7BIT = "]";
110
+ const OSC_C1 = 0x9d;
111
+ // The 8-bit C1 introducers for the same five. This doubles as the set a nested
112
+ // introducer aborts on (see scanControlString case 2) — the two ARE the same
113
+ // set: opening a string is exactly what ends the one already open.
114
+ const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
115
+
116
+ /** The seven things an introducer can turn out to be. */
98
117
  export const TOKEN_KIND = Object.freeze({
99
118
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
100
119
  SGR: "sgr",
@@ -102,6 +121,13 @@ export const TOKEN_KIND = Object.freeze({
102
121
  CSI: "csi",
103
122
  /** An OSC string: introducer, body and terminator as one unit. */
104
123
  OSC: "osc",
124
+ /**
125
+ * One of the other four ECMA-48 control strings — DCS, SOS, PM or APC —
126
+ * introducer, body and terminator as one unit, exactly like
127
+ * {@link TOKEN_KIND.OSC}. Split from it only so a warning can name what it
128
+ * found; both are payload-carrying strings and neither is benign.
129
+ */
130
+ CONTROL_STRING: "control-string",
105
131
  /**
106
132
  * A 7-bit `ESC` that starts no sequence the grammar recognizes — a truncated
107
133
  * write, a log fragment cut mid-escape, a stray byte living in a file.
@@ -122,11 +148,11 @@ export const TOKEN_KIND = Object.freeze({
122
148
  * A RAW C1 byte (U+0080-U+009F) that starts no sequence the grammar
123
149
  * recognizes. Split from {@link TOKEN_KIND.ORPHAN} because the two carry very
124
150
  * different weight: a lone `ESC` is ordinary debris in terminal output, while
125
- * a raw C1 byte is not something legitimate UTF-8 text produces, and the
126
- * block includes the string introducers DCS/SOS/PM/APC (U+0090/0098/009E/
127
- * 009F) which this grammar does not consume, so an unrecognized one here
128
- * means a terminal WOULD have swallowed the following text as a control
129
- * payload. Consumers that downgrade an inert strip to a note (see
151
+ * a raw C1 byte is not something legitimate UTF-8 text produces. The five
152
+ * string introducers in the block open a {@link TOKEN_KIND.OSC} or
153
+ * {@link TOKEN_KIND.CONTROL_STRING} token instead, so a byte that reaches
154
+ * here is one the grammar recognizes no sequence for at all — and a terminal
155
+ * may still act on it. Consumers that downgrade an inert strip to a note (see
130
156
  * `isBenignAnsiKinds` in ./layer1.mjs) must keep warning on this one.
131
157
  */
132
158
  ORPHAN_C1: "orphan-c1-introducer",
@@ -153,9 +179,10 @@ export function isOrphanKind(kind) {
153
179
  * lone `ESC`. The one place that split is decided, shared by the tokenizer and
154
180
  * by Layer 1's residual sweep (which sees bare characters, not tokens).
155
181
  *
156
- * `[` is the only lookahead that matters. `ESC ]` is an OSC, which the scanner
157
- * consumes to the end of input if unterminated (so it never reaches here), and
158
- * every other second byte `ESC (`, `ESC #`, `ESC P` — bounds what a terminal
182
+ * `[` is the only lookahead that matters. The five string introducers `ESC ]`,
183
+ * `ESC P`, `ESC X`, `ESC ^`, `ESC _` are consumed whole by
184
+ * {@link scanControlString}, to the end of input if unterminated, so they never
185
+ * reach here; every other second byte (`ESC (`, `ESC #`) bounds what a terminal
159
186
  * swallows to a byte or two rather than running until a final byte arrives.
160
187
  * @param {string} ch
161
188
  * @param {string} [next] the following character, or undefined at end of input
@@ -174,40 +201,49 @@ export function orphanKindFor(ch, next) {
174
201
  */
175
202
 
176
203
  /**
177
- * End index of the OSC string starting at `start`, or -1 if no OSC introducer
178
- * is there.
204
+ * The control string starting at `start`, or null when no string introducer is
205
+ * there.
179
206
  *
180
- * An OSC (Operating System Command) string is `<introducer> body <terminator>`:
181
- * a title, a clickable-hyperlink URL, a clipboard write i.e. attacker-
182
- * controlled PAYLOAD TEXT. Consuming the introducer alone would leave that
183
- * payload in the model's view, so the whole string is one token. Three ways it
184
- * can end:
207
+ * A control string is `<introducer> body <terminator>`: an OSC window title, a
208
+ * clickable-hyperlink URL, a clipboard write, a DCS device payload, an APC
209
+ * application command — i.e. attacker-controlled PAYLOAD TEXT in every case.
210
+ * Consuming the introducer alone would leave that payload in the model's view,
211
+ * so the whole string is one token. Three ways it can end:
185
212
  * 1. a real terminator — ST (`ESC\` or the 8-bit C1 ST U+009C) or the legacy
186
213
  * BEL — which is consumed with the body.
187
214
  * 2. an ABORT: per ECMA-48/xterm a bare ESC (one not forming ST) drops the
188
- * terminal out of the OSC string, and a nested C1 OSC introducer likewise
215
+ * terminal out of the string, and a nested C1 string introducer likewise
189
216
  * starts something new. The token ends BEFORE that byte so the scan
190
217
  * re-reads it as its own sequence — without this, an interior ESC deleted
191
218
  * the rest of the document via case 3.
192
219
  * 3. end of input, for a genuinely unterminated string: fail closed and drop
193
- * everything from the introducer on, so no OSC body survives.
220
+ * everything from the introducer on, so no body survives.
221
+ *
222
+ * BEL terminates every arm here, not just OSC. Only xterm's OSC parser accepts
223
+ * it, so a DCS ending at BEL over-consumes by the width of one body — the
224
+ * fail-closed direction, and the alternative (a per-introducer terminator set)
225
+ * makes a terminal that does accept it an under-strip.
194
226
  * @param {string} text
195
227
  * @param {number} start
196
- * @returns {number}
228
+ * @returns {{ end: number, kind: string } | null}
197
229
  */
198
- function scanOsc(text, start) {
230
+ function scanControlString(text, start) {
199
231
  const code = text.charCodeAt(start);
200
- let i = -1;
201
- if (code === OSC_C1) i = start + 1;
202
- if (code === ESC && text[start + 1] === "]") i = start + 2;
203
- if (i < 0) return -1;
232
+ const second = code === ESC ? text[start + 1] : undefined;
233
+ const sevenBit = second !== undefined && STRING_INTRO_7BIT.includes(second);
234
+ if (!sevenBit && !STRING_INTRO_C1.has(code)) return null;
235
+ const kind =
236
+ second === OSC_7BIT || code === OSC_C1
237
+ ? TOKEN_KIND.OSC
238
+ : TOKEN_KIND.CONTROL_STRING;
239
+ let i = sevenBit ? start + 2 : start + 1;
204
240
  for (; i < text.length; i++) {
205
241
  const byte = text.charCodeAt(i);
206
- if (byte === BEL || byte === ST_C1) return i + 1;
207
- if (byte === ESC) return text[i + 1] === "\\" ? i + 2 : i;
208
- if (byte === OSC_C1) return i;
242
+ if (byte === BEL || byte === ST_C1) return { end: i + 1, kind };
243
+ if (byte === ESC) return { end: text[i + 1] === "\\" ? i + 2 : i, kind };
244
+ if (STRING_INTRO_C1.has(byte)) return { end: i, kind };
209
245
  }
210
- return text.length;
246
+ return { end: text.length, kind };
211
247
  }
212
248
 
213
249
  /**
@@ -261,14 +297,17 @@ export function scanAnsi(text) {
261
297
  let match;
262
298
  while ((match = INTRODUCER_SCAN_RE.exec(text)) !== null) {
263
299
  const start = match.index;
264
- const oscEnd = scanOsc(text, start);
265
- const csiEnd = oscEnd < 0 ? scanCsi(text, start) : -1;
300
+ // The string arms run FIRST: `ESC P` also completes a two-byte CSI (`P` is a
301
+ // final byte), so letting scanCsi answer first would take the DCS
302
+ // introducer alone and leave its body as visible text.
303
+ const string = scanControlString(text, start);
304
+ const csiEnd = string ? -1 : scanCsi(text, start);
266
305
  let end = start + 1;
267
306
  /** @type {string} */
268
307
  let kind = orphanKindFor(text[start], text[start + 1]);
269
- if (oscEnd >= 0) {
270
- end = oscEnd;
271
- kind = TOKEN_KIND.OSC;
308
+ if (string) {
309
+ end = string.end;
310
+ kind = string.kind;
272
311
  } else if (csiEnd >= 0) {
273
312
  end = csiEnd;
274
313
  kind = SGR_ANCHORED_RE.test(text.slice(start, csiEnd))
package/src/layer1.mjs CHANGED
@@ -131,10 +131,9 @@ export const INERT_ANSI_NOTE =
131
131
  * mid-escape).
132
132
  *
133
133
  * The two other orphan kinds are deliberately NOT inert. A raw C1 orphan
134
- * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, and the
135
- * block holds the DCS/SOS/PM/APC string introducers this grammar does not
136
- * consume so an unrecognized one means a terminal would have eaten the
137
- * following text as a control payload. An incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
134
+ * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, so an
135
+ * unrecognized one means a terminal may still act on what follows it. An
136
+ * incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
138
137
  * for the same reason at 7 bits: the CSI parser keeps consuming until a final
139
138
  * byte, so `hello ESC[12 world` hides ` w` from the human while the model reads
140
139
  * the whole prompt.
package/types/ansi.d.mts CHANGED
@@ -12,9 +12,10 @@ export function isOrphanKind(kind: string): boolean;
12
12
  * lone `ESC`. The one place that split is decided, shared by the tokenizer and
13
13
  * by Layer 1's residual sweep (which sees bare characters, not tokens).
14
14
  *
15
- * `[` is the only lookahead that matters. `ESC ]` is an OSC, which the scanner
16
- * consumes to the end of input if unterminated (so it never reaches here), and
17
- * every other second byte `ESC (`, `ESC #`, `ESC P` — bounds what a terminal
15
+ * `[` is the only lookahead that matters. The five string introducers `ESC ]`,
16
+ * `ESC P`, `ESC X`, `ESC ^`, `ESC _` are consumed whole by
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
18
19
  * swallows to a byte or two rather than running until a final byte arrives.
19
20
  * @param {string} ch
20
21
  * @param {string} [next] the following character, or undefined at end of input
@@ -64,7 +65,7 @@ export const CONTROL_INTRODUCER_SOURCE: string;
64
65
  * and the regex can no longer describe different languages.
65
66
  */
66
67
  export const SGR_RE: RegExp;
67
- /** The six things an introducer can turn out to be. */
68
+ /** The seven things an introducer can turn out to be. */
68
69
  export const TOKEN_KIND: Readonly<{
69
70
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
70
71
  SGR: "sgr";
@@ -72,6 +73,13 @@ export const TOKEN_KIND: Readonly<{
72
73
  CSI: "csi";
73
74
  /** An OSC string: introducer, body and terminator as one unit. */
74
75
  OSC: "osc";
76
+ /**
77
+ * One of the other four ECMA-48 control strings — DCS, SOS, PM or APC —
78
+ * introducer, body and terminator as one unit, exactly like
79
+ * {@link TOKEN_KIND.OSC}. Split from it only so a warning can name what it
80
+ * found; both are payload-carrying strings and neither is benign.
81
+ */
82
+ CONTROL_STRING: "control-string";
75
83
  /**
76
84
  * A 7-bit `ESC` that starts no sequence the grammar recognizes — a truncated
77
85
  * write, a log fragment cut mid-escape, a stray byte living in a file.
@@ -92,11 +100,11 @@ export const TOKEN_KIND: Readonly<{
92
100
  * A RAW C1 byte (U+0080-U+009F) that starts no sequence the grammar
93
101
  * recognizes. Split from {@link TOKEN_KIND.ORPHAN} because the two carry very
94
102
  * different weight: a lone `ESC` is ordinary debris in terminal output, while
95
- * a raw C1 byte is not something legitimate UTF-8 text produces, and the
96
- * block includes the string introducers DCS/SOS/PM/APC (U+0090/0098/009E/
97
- * 009F) which this grammar does not consume, so an unrecognized one here
98
- * means a terminal WOULD have swallowed the following text as a control
99
- * payload. Consumers that downgrade an inert strip to a note (see
103
+ * a raw C1 byte is not something legitimate UTF-8 text produces. The five
104
+ * string introducers in the block open a {@link TOKEN_KIND.OSC} or
105
+ * {@link TOKEN_KIND.CONTROL_STRING} token instead, so a byte that reaches
106
+ * here is one the grammar recognizes no sequence for at all — and a terminal
107
+ * may still act on it. Consumers that downgrade an inert strip to a note (see
100
108
  * `isBenignAnsiKinds` in ./layer1.mjs) must keep warning on this one.
101
109
  */
102
110
  ORPHAN_C1: "orphan-c1-introducer";
@@ -22,10 +22,9 @@ export function stripAnsiFully(input: string, kinds?: Set<string>): string;
22
22
  * mid-escape).
23
23
  *
24
24
  * The two other orphan kinds are deliberately NOT inert. A raw C1 orphan
25
- * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, and the
26
- * block holds the DCS/SOS/PM/APC string introducers this grammar does not
27
- * consume so an unrecognized one means a terminal would have eaten the
28
- * following text as a control payload. An incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
25
+ * (TOKEN_KIND.ORPHAN_C1): legit UTF-8 text does not carry raw C1 bytes, so an
26
+ * unrecognized one means a terminal may still act on what follows it. An
27
+ * incomplete CSI (TOKEN_KIND.ORPHAN_CSI)
29
28
  * for the same reason at 7 bits: the CSI parser keeps consuming until a final
30
29
  * byte, so `hello ESC[12 world` hides ` w` from the human while the model reads
31
30
  * the whole prompt.