agent-sanitizer 2.34.4 → 2.34.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.
@@ -53,11 +53,36 @@ import { lazyImport } from "./hook-io.mjs";
53
53
  const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
54
54
  await lazyImport("agent-sanitizer")
55
55
  );
56
- const { STRIP, LONG_RUN_RE, SCATTERED_THRESHOLD, stripInvisible } =
56
+ const { STRIP, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, stripInvisible } =
57
57
  /** @type {typeof import("agent-sanitizer/invisible")} */ (
58
58
  await lazyImport("agent-sanitizer/invisible")
59
59
  );
60
60
 
61
+ /**
62
+ * "A run of {@link LONG_RUN_THRESHOLD} or more invisibles", bounded per match.
63
+ *
64
+ * Built from the engine's own class and threshold rather than imported as a
65
+ * ready-made pattern or scan function, because the bundle resolves
66
+ * `agent-sanitizer` to the PINNED published engine, which trails this repo:
67
+ * anything this hook imports has to exist in that pin, or the import binds
68
+ * undefined and the hook fails closed on every payload. STRIP and
69
+ * LONG_RUN_THRESHOLD are the primitives that define a long run, so deriving the
70
+ * pattern here keeps the answer identical to the engine's across pins, with no
71
+ * version-specific scan API to adopt when the pin moves.
72
+ *
73
+ * The upper bound is what makes it safe on a large payload: V8 pushes one
74
+ * backtrack entry per iteration of a quantifier onto a stack capped at 64 MB,
75
+ * so an UNBOUNDED run pattern throws `RangeError: Maximum call stack size
76
+ * exceeded` once a single run passes ~8.4 M code points — an 8 MB paste of
77
+ * zero-widths into a Write body is exactly that. A bound of 2^20 iterations
78
+ * sits ~8x under the ceiling, and a longer run still answers yes: any run of at
79
+ * least the threshold contains a prefix this matches.
80
+ */
81
+ const LONG_RUN_CHUNK_RE = new RegExp(
82
+ `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${1 << 20}}`,
83
+ "gu",
84
+ );
85
+
61
86
  // Content fields the model authors, per tool. Paths and confusables are the
62
87
  // confusable layer's domain; here we target the free-text fields that carry
63
88
  // model-authored prose / code / data out into persisted or displayed artifacts.
@@ -140,8 +165,8 @@ export function authoredScopeDecision(tool) {
140
165
  // user→model surfaces share one definition of "stego payload".
141
166
  /** @param {string} text */
142
167
  function isPayloadCapable(text) {
143
- LONG_RUN_RE.lastIndex = 0;
144
- if (LONG_RUN_RE.test(text)) return true;
168
+ LONG_RUN_CHUNK_RE.lastIndex = 0;
169
+ if (LONG_RUN_CHUNK_RE.test(text)) return true;
145
170
  return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
146
171
  }
147
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.34.4",
3
+ "version": "2.34.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": {
@@ -281,20 +281,38 @@ export function selectFoldableFindings(text, findings) {
281
281
  * @returns {string}
282
282
  */
283
283
  export function foldConfusables(text, findings) {
284
- let folded = text;
284
+ // The folded text is `text.slice(0, cursor)` followed by `tail` read
285
+ // BACKWARDS: each finding appends the gap that follows it and then its
286
+ // replacement, so the string is assembled once at the end. Splicing a fresh
287
+ // string per finding instead costs O(findings x length) — a 128 KB command
288
+ // stuffed with look-alikes took 1.8 s of the PreToolUse hook that way.
289
+ /** @type {string[]} */
290
+ const tail = [];
291
+ let cursor = text.length;
292
+ const rebuild = () => [...tail].reverse().join("");
285
293
  for (const finding of [...findings].sort(
286
294
  (lhs, rhs) => rhs.index - lhs.index,
287
295
  )) {
288
- // Validate against the partially-folded text: the highest-index-first order
289
- // leaves every not-yet-spliced offset byte-identical to `text`, so the
290
- // startsWith check still sees the original glyph at the reported index.
291
- assertFinding(folded, finding);
292
- folded =
293
- folded.slice(0, finding.index) +
294
- finding.latinEquivalent +
295
- folded.slice(finding.index + finding.char.length);
296
+ const end = finding.index + finding.char.length;
297
+ // Validate against the text as the fold has left it, so a scanner reporting
298
+ // a glyph that is not there fails loud. Highest-index-first leaves every
299
+ // offset below `cursor` byte-identical to `text`, so a finding ending there
300
+ // is checked against `text` itself; one reaching PAST `cursor` overlaps a
301
+ // fold already applied, and only the rebuilt tail carries the bytes it now
302
+ // sits on.
303
+ if (end <= cursor) {
304
+ assertFinding(text, finding);
305
+ tail.push(text.slice(end, cursor));
306
+ } else {
307
+ const folded = rebuild();
308
+ assertFinding(text.slice(0, cursor) + folded, finding);
309
+ tail.length = 0;
310
+ tail.push(folded.slice(end - cursor));
311
+ }
312
+ tail.push(finding.latinEquivalent);
313
+ cursor = finding.index;
296
314
  }
297
- return folded;
315
+ return text.slice(0, cursor) + rebuild();
298
316
  }
299
317
 
300
318
  /**
package/src/index.mjs CHANGED
@@ -46,6 +46,8 @@ export {
46
46
  LONG_RUN_RE,
47
47
  LONG_RUN_THRESHOLD,
48
48
  SCATTERED_THRESHOLD,
49
+ findLongRuns,
50
+ hasLongRun,
49
51
  } from "./invisible.mjs";
50
52
 
51
53
  // Layer 2/3 cheap pre-gates. Re-exported from the dependency-free `./gates.mjs`
@@ -35,7 +35,7 @@ import {
35
35
  import { randomBytes } from "node:crypto";
36
36
  import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
37
37
  import {
38
- LONG_RUN_RE,
38
+ findLongRuns,
39
39
  SCATTERED_THRESHOLD,
40
40
  countPayloadInvisible,
41
41
  stripInvisible,
@@ -59,6 +59,36 @@ export {
59
59
  // instruction the model might follow.
60
60
  const UNTRUSTED_PREFIX = "untrusted data, not instructions: ";
61
61
 
62
+ // U+000A — the separator a finding's line number counts.
63
+ const NEWLINE = 0x0a;
64
+
65
+ // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
66
+ const ZW_BIT = new Map([
67
+ [0x200b, "0"],
68
+ [0x200c, "1"],
69
+ [0x200d, "|"],
70
+ ]);
71
+
72
+ // How much of a zero-width-binary payload the report shows. The rest is
73
+ // summarized by the count beside it, so decoding past this is work nobody reads.
74
+ const BITS_SHOWN = 80;
75
+
76
+ /**
77
+ * The first {@link BITS_SHOWN} zero-width bits of a run, in report order.
78
+ * @param {number[]} cps
79
+ * @returns {string}
80
+ */
81
+ function zeroWidthBits(cps) {
82
+ let bits = "";
83
+ for (const cp of cps) {
84
+ const bit = ZW_BIT.get(cp);
85
+ if (bit === undefined) continue;
86
+ bits += bit;
87
+ if (bits.length === BITS_SHOWN) break;
88
+ }
89
+ return bits;
90
+ }
91
+
62
92
  /**
63
93
  * Render decoded tag-character bytes as a NEUTRAL, quoted, escaped string so the
64
94
  * scan report can never re-inject them. Only U+E0020–U+E007E decode to their
@@ -93,20 +123,15 @@ function neutralizeTagBytes(asciiCodes) {
93
123
  * @returns {{ method: string, decoded: string }}
94
124
  */
95
125
  export function decodeRun(run) {
96
- const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
126
+ /** @type {number[]} */
127
+ const cps = [];
128
+ for (const ch of run) cps.push(/** @type {number} */ (ch.codePointAt(0)));
97
129
 
98
130
  // Tag characters U+E0001-U+E007F: raw ASCII byte is cp − 0xE0000 (0x01–0x7F).
99
131
  const tagBytes = cps
100
132
  .filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
101
133
  .map((cp) => cp - 0xe0000);
102
134
 
103
- // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
104
- const ZW_BIT = new Map([
105
- [0x200b, "0"],
106
- [0x200c, "1"],
107
- [0x200d, "|"],
108
- ]);
109
-
110
135
  const zwCount = cps.filter((cp) => ZW_BIT.has(cp)).length;
111
136
 
112
137
  // Only take the tag-characters branch when tag chars are the MAJORITY of the
@@ -130,15 +155,11 @@ export function decodeRun(run) {
130
155
  // the binary payload it actually is, not mislabeled). Decode only the ZW code
131
156
  // points; a `+ N other char(s)` note keeps any non-ZW portion visible.
132
157
  if (zwCount > 0 && zwCount > cps.length / 2) {
133
- const bits = cps
134
- .filter((cp) => ZW_BIT.has(cp))
135
- .map((cp) => ZW_BIT.get(cp))
136
- .join("");
137
158
  const otherCount = cps.length - zwCount;
138
159
  const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
139
160
  return {
140
161
  method: "zero-width binary encoding",
141
- decoded: `[${zwCount} zero-width chars: ${bits.slice(0, 80)}]${note}`,
162
+ decoded: `[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]${note}`,
142
163
  };
143
164
  }
144
165
 
@@ -151,13 +172,8 @@ export function decodeRun(run) {
151
172
  const parts = [];
152
173
  if (tagBytes.length > 0)
153
174
  parts.push(`${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"`);
154
- if (zwCount > 0) {
155
- const bits = cps
156
- .filter((cp) => ZW_BIT.has(cp))
157
- .map((cp) => ZW_BIT.get(cp))
158
- .join("");
159
- parts.push(`[${zwCount} zero-width chars: ${bits.slice(0, 80)}]`);
160
- }
175
+ if (zwCount > 0)
176
+ parts.push(`[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]`);
161
177
  const otherCount = cps.length - tagBytes.length - zwCount;
162
178
  const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
163
179
  return {
@@ -186,14 +202,24 @@ export function decodeRun(run) {
186
202
  */
187
203
  export function scanText(content) {
188
204
  const findings = [];
189
- LONG_RUN_RE.lastIndex = 0;
190
- let match;
191
205
  let runChars = 0;
192
- while ((match = LONG_RUN_RE.exec(content)) !== null) {
193
- const lineNum = content.slice(0, match.index).split("\n").length;
194
- const charCount = [...match[0]].length;
195
- runChars += charCount;
196
- findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
206
+ // The line number is carried forward across runs. Deriving it per run from
207
+ // the start of the file — `content.slice(0, run.index).split("\n")` — copies
208
+ // the whole prefix and materializes every line before the run, so a file
209
+ // carrying many runs pays that once per run: quadratic in the file length, on
210
+ // the SessionStart path the user waits for. Runs arrive in increasing index
211
+ // order, so this scan only ever moves forward.
212
+ let line = 1;
213
+ let counted = 0;
214
+ for (const run of findLongRuns(content)) {
215
+ for (; counted < run.index; counted++)
216
+ if (content.charCodeAt(counted) === NEWLINE) line++;
217
+ runChars += run.charCount;
218
+ findings.push({
219
+ line,
220
+ charCount: run.charCount,
221
+ ...decodeRun(run.text),
222
+ });
197
223
  }
198
224
 
199
225
  // Threshold-evasion: scattered invisible chars not in a long run can still be
package/src/invisible.mjs CHANGED
@@ -157,11 +157,93 @@ export const LONG_RUN_THRESHOLD = 10;
157
157
  * payload-capable even without a long run (threshold-evasion catch). */
158
158
  export const SCATTERED_THRESHOLD = 30;
159
159
 
160
+ /**
161
+ * The long-run pattern, declaratively: {@link LONG_RUN_THRESHOLD} or more
162
+ * consecutive {@link STRIP} code points.
163
+ *
164
+ * Scan a document with {@link findLongRuns}, not with this: `exec`/`test`
165
+ * throw `RangeError: Maximum call stack size exceeded` once a run passes
166
+ * ~8.4 M code points, because V8 pushes one backtrack entry per iteration of
167
+ * an unbounded quantifier onto a stack capped at 64 MB. This stays public as
168
+ * the pattern itself, and as the independent oracle the scan is differenced
169
+ * against (test/invisible-fast-path.test.mjs).
170
+ */
160
171
  export const LONG_RUN_RE = new RegExp(
161
172
  `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},}`,
162
173
  REGEX_FLAGS,
163
174
  );
164
175
 
176
+ // Iterations per `exec` below, which is what bounds the backtrack stack each
177
+ // one needs: a match can push at most this many entries, ~8x under the ceiling
178
+ // an unbounded quantifier walks into on an 8 MB payload. Runs longer than this
179
+ // are stitched from consecutive matches, so the bound costs an extra `exec`
180
+ // per megabyte of PAYLOAD and nothing at all on ordinary text.
181
+ const RUN_CHUNK = 1 << 20;
182
+
183
+ const LONG_RUN_CHUNK_RE = new RegExp(
184
+ `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${RUN_CHUNK}}`,
185
+ REGEX_FLAGS,
186
+ );
187
+
188
+ // The same class, sticky and from one repetition, to carry a run past the chunk
189
+ // bound: anchored at the end of the previous match, it either extends the run
190
+ // or fails immediately.
191
+ const RUN_TAIL_RE = new RegExp(`(?:${STRIP.source}){1,${RUN_CHUNK}}`, "yu");
192
+
193
+ /**
194
+ * Every maximal run of at least {@link LONG_RUN_THRESHOLD} consecutive
195
+ * payload-capable invisible code points in `text`, in order: `index` is the
196
+ * run's UTF-16 offset, `text` its verbatim slice, `charCount` its length in
197
+ * code points.
198
+ *
199
+ * What {@link LONG_RUN_RE} means, in the form every scanner in this package
200
+ * uses — because that regex cannot answer for a large document, and an 8 MB
201
+ * paste of zero-widths (the exact payload the scan exists to catch) is what
202
+ * took out the SessionStart scanner, the prompt gate and the tool-output tier
203
+ * alike. Bounding the quantifier bounds the backtrack stack per `exec`; a run
204
+ * that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
205
+ * runs reported are maximal at any length.
206
+ * @param {string} text
207
+ * @returns {Generator<{ index: number, text: string, charCount: number }>}
208
+ */
209
+ export function* findLongRuns(text) {
210
+ // Both regexes are module-level and carry `lastIndex`, and a generator can be
211
+ // suspended anywhere — including inside another scan of another text. Every
212
+ // exec below therefore sets its own start position first, so no scan can
213
+ // inherit a position from one it interleaved with.
214
+ let pos = 0;
215
+ for (;;) {
216
+ LONG_RUN_CHUNK_RE.lastIndex = pos;
217
+ const match = LONG_RUN_CHUNK_RE.exec(text);
218
+ if (match === null) return;
219
+ let end = LONG_RUN_CHUNK_RE.lastIndex;
220
+ // A run shorter than the bound fails this on the first try, for the cost of
221
+ // one anchored no-match.
222
+ for (;;) {
223
+ RUN_TAIL_RE.lastIndex = end;
224
+ if (RUN_TAIL_RE.exec(text) === null) break;
225
+ end = RUN_TAIL_RE.lastIndex;
226
+ }
227
+ const run = text.slice(match.index, end);
228
+ yield { index: match.index, text: run, charCount: codePointLength(run) };
229
+ pos = end;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * True when `text` carries at least one {@link findLongRuns} run.
235
+ *
236
+ * The bounded pattern answers this on its own: a run long enough to be reported
237
+ * is long enough to match, whether or not the match reaches the run's end — so
238
+ * the yes/no costs one anchored scan and never measures the run.
239
+ * @param {string} text
240
+ * @returns {boolean}
241
+ */
242
+ export function hasLongRun(text) {
243
+ LONG_RUN_CHUNK_RE.lastIndex = 0;
244
+ return LONG_RUN_CHUNK_RE.test(text);
245
+ }
246
+
165
247
  /**
166
248
  * The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
167
249
  * labels, the LONG RUN marker when the de-ANSI'd text still holds a
@@ -1150,12 +1232,10 @@ export function payloadLongRunSample(text) {
1150
1232
  // The view is code-point-for-code-point with `text` and only ever REPLACES an
1151
1233
  // invisible with a space, so a run in the view is a run in `text`: no long run
1152
1234
  // in the raw text means none in the view. This hides no payload — the bulk
1153
- // regex reads the whole text, and a run it finds still goes through the full
1235
+ // scan reads the whole text, and a run it finds still goes through the full
1154
1236
  // carve analysis below to decide what of it is really payload.
1155
- LONG_RUN_RE.lastIndex = 0;
1156
- if (!LONG_RUN_RE.test(text)) return null;
1157
- LONG_RUN_RE.lastIndex = 0;
1158
- return payloadInvisibleView(text).match(LONG_RUN_RE)?.[0] ?? null;
1237
+ if (!hasLongRun(text)) return null;
1238
+ return findLongRuns(payloadInvisibleView(text)).next().value?.text ?? null;
1159
1239
  }
1160
1240
 
1161
1241
  /**
package/types/index.d.mts CHANGED
@@ -53,5 +53,5 @@ export function sanitize(text: string, options?: {
53
53
  }>;
54
54
  }>;
55
55
  export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
56
- export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
56
+ export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, findLongRuns, hasLongRun } from "./invisible.mjs";
57
57
  export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint } from "./gates.mjs";
@@ -14,6 +14,37 @@
14
14
  * @returns {boolean}
15
15
  */
16
16
  export function isSgrOnly(text: string): boolean;
17
+ /**
18
+ * Every maximal run of at least {@link LONG_RUN_THRESHOLD} consecutive
19
+ * payload-capable invisible code points in `text`, in order: `index` is the
20
+ * run's UTF-16 offset, `text` its verbatim slice, `charCount` its length in
21
+ * code points.
22
+ *
23
+ * What {@link LONG_RUN_RE} means, in the form every scanner in this package
24
+ * uses — because that regex cannot answer for a large document, and an 8 MB
25
+ * paste of zero-widths (the exact payload the scan exists to catch) is what
26
+ * took out the SessionStart scanner, the prompt gate and the tool-output tier
27
+ * alike. Bounding the quantifier bounds the backtrack stack per `exec`; a run
28
+ * that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
29
+ * runs reported are maximal at any length.
30
+ * @param {string} text
31
+ * @returns {Generator<{ index: number, text: string, charCount: number }>}
32
+ */
33
+ export function findLongRuns(text: string): Generator<{
34
+ index: number;
35
+ text: string;
36
+ charCount: number;
37
+ }>;
38
+ /**
39
+ * True when `text` carries at least one {@link findLongRuns} run.
40
+ *
41
+ * The bounded pattern answers this on its own: a run long enough to be reported
42
+ * is long enough to match, whether or not the match reaches the run's end — so
43
+ * the yes/no costs one anchored scan and never measures the run.
44
+ * @param {string} text
45
+ * @returns {boolean}
46
+ */
47
+ export function hasLongRun(text: string): boolean;
17
48
  /**
18
49
  * The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
19
50
  * labels, the LONG RUN marker when the de-ANSI'd text still holds a
@@ -162,6 +193,17 @@ export const LONG_RUN_THRESHOLD: 10;
162
193
  /** Total invisible-char count above which a file/prompt is treated as
163
194
  * payload-capable even without a long run (threshold-evasion catch). */
164
195
  export const SCATTERED_THRESHOLD: 30;
196
+ /**
197
+ * The long-run pattern, declaratively: {@link LONG_RUN_THRESHOLD} or more
198
+ * consecutive {@link STRIP} code points.
199
+ *
200
+ * Scan a document with {@link findLongRuns}, not with this: `exec`/`test`
201
+ * throw `RangeError: Maximum call stack size exceeded` once a run passes
202
+ * ~8.4 M code points, because V8 pushes one backtrack entry per iteration of
203
+ * an unbounded quantifier onto a stack capped at 64 MB. This stays public as
204
+ * the pattern itself, and as the independent oracle the scan is differenced
205
+ * against (test/invisible-fast-path.test.mjs).
206
+ */
165
207
  export const LONG_RUN_RE: RegExp;
166
208
  export const CONSECUTIVE_JOINER_CAP: 8;
167
209
  export const CONSECUTIVE_SELECTOR_CAP: 8;