agent-sanitizer 2.31.5 → 2.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THREAT-MODEL.md +7 -5
- package/package.json +1 -1
- package/src/ansi.mjs +76 -37
- package/src/layer1.mjs +3 -4
- package/types/ansi.d.mts +17 -9
- package/types/layer1.d.mts +3 -4
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.
|
|
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
|
|
85
|
-
introducer is dropped through end-of-string (fail-closed), so no
|
|
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.
|
|
3
|
+
"version": "2.32.0",
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
*
|
|
178
|
-
*
|
|
204
|
+
* The control string starting at `start`, or null when no string introducer is
|
|
205
|
+
* there.
|
|
179
206
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* payload in the model's view,
|
|
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
|
|
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
|
|
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
|
|
230
|
+
function scanControlString(text, start) {
|
|
199
231
|
const code = text.charCodeAt(start);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
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 (
|
|
270
|
-
end =
|
|
271
|
-
kind =
|
|
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,
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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";
|
package/types/layer1.d.mts
CHANGED
|
@@ -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,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|