agent-sanitizer 2.34.5 → 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.5",
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": {
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,
@@ -202,23 +202,24 @@ export function decodeRun(run) {
202
202
  */
203
203
  export function scanText(content) {
204
204
  const findings = [];
205
- LONG_RUN_RE.lastIndex = 0;
206
- let match;
207
205
  let runChars = 0;
208
- // The line number is carried forward across matches. Deriving it per match
209
- // from the start of the file — `content.slice(0, match.index).split("\n")` —
210
- // copies the whole prefix and materializes every line before the match, so a
211
- // file carrying many runs pays that once per run: quadratic in the file
212
- // length, on the SessionStart path the user waits for. `exec` yields matches
213
- // in increasing index order, so this scan only ever moves forward.
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.
214
212
  let line = 1;
215
213
  let counted = 0;
216
- while ((match = LONG_RUN_RE.exec(content)) !== null) {
217
- for (; counted < match.index; counted++)
214
+ for (const run of findLongRuns(content)) {
215
+ for (; counted < run.index; counted++)
218
216
  if (content.charCodeAt(counted) === NEWLINE) line++;
219
- const charCount = [...match[0]].length;
220
- runChars += charCount;
221
- findings.push({ line, charCount, ...decodeRun(match[0]) });
217
+ runChars += run.charCount;
218
+ findings.push({
219
+ line,
220
+ charCount: run.charCount,
221
+ ...decodeRun(run.text),
222
+ });
222
223
  }
223
224
 
224
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;