agent-sanitizer 2.48.6 → 2.49.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/THREAT-MODEL.md CHANGED
@@ -779,6 +779,72 @@ The knob adds no layer and changes no layer's semantics. Ambiguous input still
779
779
  fails open at the detection level (precision over recall), as it always has —
780
780
  that is a separate, and unrelated, sense of the phrase.
781
781
 
782
+ ## Linear-time guarantee
783
+
784
+ Every layer here reads attacker-controlled text inside a hook the user waits on,
785
+ so a cost blow-up is a denial of service against the agent: the sanitizer that
786
+ hangs on a crafted paste has failed as completely as one that misses the
787
+ payload. This is a property of the whole surface, not of any one layer, so it is
788
+ stated once here rather than restated per layer.
789
+
790
+ **The contract.** No entry point costs more than `n log n` in its input: linear
791
+ up to the constant factors a parse pays, and one sort where findings or splice
792
+ ranges arrive in an order the caller does not promise (`foldConfusables`,
793
+ `mergeRanges`). The bound that matters is that nothing is _quadratic_ — a sort
794
+ of the findings in a 1 MB paste is milliseconds, a quadratic rescan of it is
795
+ minutes — and quadratic is what the two gates below are calibrated to catch,
796
+ because neither can see what the other does.
797
+
798
+ **Static — every pattern.** `tests/test_redos_js_static_guard.py` drives
799
+ regexploit over an inventory that `scripts/extract-js-regexes.mjs` extracts by
800
+ walking the TypeScript AST of every shipped `.mjs` (not by matching regex source
801
+ with a regex), and fails on any pattern with super-linear backtracking. A
802
+ `RegExp(…)` built at runtime must be statically resolvable or carry a written
803
+ exemption, so a new dynamic pattern is a red build rather than a hole.
804
+ `tests/secrets/test_redos_static_guard.py` is the twin over the Python engine,
805
+ and `eslint-plugin-redos` reports the same class in the editor, before CI.
806
+
807
+ **Empirical — everything the static gates cannot see.** Two shapes of quadratic
808
+ carry no backtracking at all, so no pattern analyzer will ever report them:
809
+
810
+ - an **unanchored** pattern retried at every start offset, each attempt linear
811
+ and none of them backtracking — `(?=.*[A-Z])(?=.*[0-9])` against a long value
812
+ carrying neither class, or a `[^>]*$` tail rescanned from every `<`;
813
+ - work that is not a pattern at all — a string rebuilt once per finding, a
814
+ prefix re-sliced inside a loop, a re-walk of an already-walked node.
815
+
816
+ `test/algorithmic-complexity.test.mjs` runs an entry point over an adversarial
817
+ input at two sizes 8x apart and asserts the cost grew like the input rather than
818
+ like its square, with a known-quadratic specimen as the control that proves the
819
+ harness can still see one. Each case also asserts, at the size it measures, that
820
+ the input still reaches the code the case is about — a length gate answering
821
+ first is how such a case quietly degrades into timing nothing.
822
+ `tests/secrets/test_algorithmic_complexity.py` is the twin over the Python
823
+ engine. `test/hook-latency.test.mjs` covers the three blocking hook paths
824
+ instead, in cost per calibration pass, with one wall-clock ceiling.
825
+
826
+ **Idioms new code is held to.** Each is the fix for a defect one of the gates
827
+ caught:
828
+
829
+ - Anchor a tail scan, or slice the tail first (`lastIndexOf(">")`) and match a
830
+ fixed-length pattern against it — never `[^x]*$` over the whole input.
831
+ - Spell alternatives disjointly (`\d+(?:\.\d+)?|\.\d+`, not `\d*\.?\d+`) so a
832
+ failing match gives up in constant time per step rather than retrying every
833
+ split of a long run.
834
+ - Bound an unbounded quantifier that can span the input, and stitch the chunks
835
+ (`src/invisible.mjs`'s `LONG_RUN_CHUNK_RE`), so the backtrack stack has a
836
+ ceiling that does not move with the input.
837
+ - Assemble a rewritten string once, from an array of pieces — never
838
+ `text.slice(0, i) + x + text.slice(j)` once per finding.
839
+ - Bound every recursive walk by depth and memo, and thread the wall-clock
840
+ `Deadline` through the layers that can be skipped, so a budget already spent
841
+ refuses the work instead of starting it.
842
+
843
+ The guarantee is about COST, not about verdicts. Where a bound and a detection
844
+ disagree — a value too long to analyze, a walk past `MAX_DEPTH`, a spent
845
+ deadline — the layer takes the false negative and says so, exactly as the
846
+ precision-over-recall doctrine requires everywhere else.
847
+
782
848
  ## Benchmark (`test/injection-corpus.test.mjs`)
783
849
 
784
850
  The published "invisible prompt injection" corpora fall in two families, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.48.6",
3
+ "version": "2.49.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": {
@@ -59,6 +59,7 @@
59
59
  "c8": "11.0.0",
60
60
  "esbuild": "0.28.1",
61
61
  "eslint": "10.4.0",
62
+ "eslint-plugin-redos": "4.5.0",
62
63
  "fast-check": "4.8.0",
63
64
  "globals": "17.6.0",
64
65
  "lint-staged": "^17.0.5",
@@ -198,21 +198,21 @@ function describeFolds(findings) {
198
198
 
199
199
  /**
200
200
  * Reject a finding that does not describe a real, foldable glyph at its reported
201
- * offset in `text`. Every consumer of a scanner finding runs this BEFORE acting
202
- * on it, so a buggy or adversarial scanner fails loud instead of silently
203
- * corrupting (or silently escaping the fold gate in) a path/command.
204
- * @param {string} text
201
+ * offset. Every consumer of a scanner finding runs this BEFORE acting on it, so
202
+ * a buggy or adversarial scanner fails loud instead of silently corrupting (or
203
+ * silently escaping the fold gate in) a path/command. `actual` is the bytes that
204
+ * sit at `finding.index` right now — from the input for an untouched region,
205
+ * from the folded output for one the caller has already rewritten.
205
206
  * @param {{ index: number, char: string, latinEquivalent: string }} finding
207
+ * @param {string} actual
206
208
  * @returns {void}
207
209
  */
208
- function assertFinding(text, finding) {
209
- // Fail loud on a finding that does not match the actual bytes at its offset:
210
- // a buggy/adversarial scanner reporting a wrong char/index would otherwise
211
- // silently corrupt the path/command, defeating the deny-rule protection.
212
- // A negative index is the gap the startsWith guard alone misses: when `char`
213
- // is a prefix of the text, `startsWith(char, -1)` is true (the offset is
214
- // clamped to 0), and the slice math below then mangles the string instead of
215
- // throwing — so range-check the index explicitly first.
210
+ function assertFinding(finding, actual) {
211
+ // A negative index is the gap the byte check alone misses: `String.slice`
212
+ // reads a negative offset from the END, so `actual` can be a genuine match
213
+ // taken from the wrong place `{index: -2, char: "а"}` against `"aаb"`
214
+ // matches, and the splice math then folds a byte the finding never named.
215
+ // Range-check the index explicitly first.
216
216
  if (!Number.isInteger(finding.index) || finding.index < 0)
217
217
  throw new Error(
218
218
  `Confusable finding has an out-of-range index ${finding.index}`,
@@ -235,7 +235,7 @@ function assertFinding(text, finding) {
235
235
  throw new Error(
236
236
  `Confusable finding at index ${finding.index} names an ASCII char ${JSON.stringify(finding.char)}`,
237
237
  );
238
- if (!text.startsWith(finding.char, finding.index))
238
+ if (!actual.startsWith(finding.char))
239
239
  throw new Error(
240
240
  `Confusable finding does not match input at index ${finding.index}: expected ${JSON.stringify(finding.char)}`,
241
241
  );
@@ -264,6 +264,17 @@ function assertFinding(text, finding) {
264
264
  );
265
265
  }
266
266
 
267
+ /**
268
+ * The bytes `finding.char` claims to sit on, read from `text`. Bounded to the
269
+ * glyph's own length so validating a finding never copies the rest of the input.
270
+ * @param {string} text
271
+ * @param {{ index: number, char: string }} finding
272
+ * @returns {string}
273
+ */
274
+ function bytesAt(text, finding) {
275
+ return text.slice(finding.index, finding.index + finding.char.length);
276
+ }
277
+
267
278
  /**
268
279
  * True for a code point that ends a token: any ASCII character that is not a
269
280
  * letter or digit (space, `/`, `.`, `-`, `_`, quotes, shell metacharacters). A
@@ -295,7 +306,8 @@ function isTokenBoundary(ch) {
295
306
  * @returns {Array<{ index: number, char: string, latinEquivalent: string }>}
296
307
  */
297
308
  export function selectFoldableFindings(text, findings) {
298
- for (const finding of findings) assertFinding(text, finding);
309
+ for (const finding of findings)
310
+ assertFinding(finding, bytesAt(text, finding));
299
311
 
300
312
  // Every UTF-16 offset a finding covers, not just its start: a scanner is free
301
313
  // to report a multi-code-point match, and treating only the first offset as
@@ -341,10 +353,15 @@ export function selectFoldableFindings(text, findings) {
341
353
 
342
354
  // Every offset the finding covers, not just its first: `char` may span a
343
355
  // boundary into a token the gate rejected, and folding it would rewrite that
344
- // token — the exact mangling this gate exists to prevent.
345
- return findings.filter((finding) =>
346
- [...finding.char].every((_, i) => foldableAt[finding.index + i] === 1),
347
- );
356
+ // token — the exact mangling this gate exists to prevent. Counted in UTF-16
357
+ // units, which is what `foldableAt` is indexed by: an astral glyph occupies
358
+ // two of them, so a code-point walk would leave the tail of a `char` that
359
+ // carries one unchecked.
360
+ return findings.filter((finding) => {
361
+ for (let i = 0; i < finding.char.length; i++)
362
+ if (foldableAt[finding.index + i] !== 1) return false;
363
+ return true;
364
+ });
348
365
  }
349
366
 
350
367
  /**
@@ -365,7 +382,6 @@ export function foldConfusables(text, findings) {
365
382
  /** @type {string[]} */
366
383
  const tail = [];
367
384
  let cursor = text.length;
368
- const rebuild = () => [...tail].reverse().join("");
369
385
  for (const finding of [...findings].sort(
370
386
  (lhs, rhs) => rhs.index - lhs.index,
371
387
  )) {
@@ -374,21 +390,53 @@ export function foldConfusables(text, findings) {
374
390
  // a glyph that is not there fails loud. Highest-index-first leaves every
375
391
  // offset below `cursor` byte-identical to `text`, so a finding ending there
376
392
  // is checked against `text` itself; one reaching PAST `cursor` overlaps a
377
- // fold already applied, and only the rebuilt tail carries the bytes it now
378
- // sits on.
393
+ // fold already applied, and only the folded bytes carry what it now sits on.
379
394
  if (end <= cursor) {
380
- assertFinding(text, finding);
395
+ assertFinding(finding, bytesAt(text, finding));
381
396
  tail.push(text.slice(end, cursor));
382
397
  } else {
383
- const folded = rebuild();
384
- assertFinding(text.slice(0, cursor) + folded, finding);
385
- tail.length = 0;
386
- tail.push(folded.slice(end - cursor));
398
+ // The overlap reaches at most `char.length` past `cursor`, and those bytes
399
+ // are the ones pushed most recently, so take them off the tail instead of
400
+ // re-joining all of it — a whole-tail rebuild per overlapping finding is
401
+ // the O(findings x length) cost this assembly exists to avoid. Consuming
402
+ // them is also what the fold means: `char` replaces those bytes.
403
+ const overlap = takeFromTail(tail, end - cursor);
404
+ assertFinding(finding, text.slice(finding.index, cursor) + overlap);
387
405
  }
388
406
  tail.push(finding.latinEquivalent);
389
407
  cursor = finding.index;
390
408
  }
391
- return text.slice(0, cursor) + rebuild();
409
+ return text.slice(0, cursor) + tail.reverse().join("");
410
+ }
411
+
412
+ /**
413
+ * Remove and return the first `count` characters of the folded tail. The tail is
414
+ * stored back-to-front, so its lowest-offset bytes are the entries pushed most
415
+ * recently and popping walks them in reading order; a partially consumed entry
416
+ * is pushed back minus what was taken.
417
+ * @param {string[]} tail
418
+ * @param {number} count
419
+ * @returns {string}
420
+ */
421
+ function takeFromTail(tail, count) {
422
+ /** @type {string[]} */
423
+ const taken = [];
424
+ let remaining = count;
425
+ while (remaining > 0) {
426
+ const entry = tail.pop();
427
+ // A tail shorter than `count` means the finding claims bytes past the end of
428
+ // the folded text — a mismatching finding, which the caller's assertion
429
+ // rejects by name once it sees the short result. Stop rather than pretend.
430
+ if (entry === undefined) break;
431
+ if (entry.length > remaining) {
432
+ taken.push(entry.slice(0, remaining));
433
+ tail.push(entry.slice(remaining));
434
+ break;
435
+ }
436
+ taken.push(entry);
437
+ remaining -= entry.length;
438
+ }
439
+ return taken.join("");
392
440
  }
393
441
 
394
442
  /**
package/src/html.mjs CHANGED
@@ -590,13 +590,19 @@ function hexByte(n) {
590
590
  * clamps out-of-range) or a percentage `0%..100%` scaled to `0..255`. Returns
591
591
  * null (fail open) on any other shape — a `none`/`calc()`/negative channel we
592
592
  * cannot resolve to a concrete byte.
593
+ *
594
+ * The number shape here (and in `hueDegrees`/`hslPercent`) spells the integer
595
+ * and leading-`.` forms as disjoint alternatives rather than `\d*\.?\d+`: the
596
+ * latter's `\d*` and `\d+` both match a digit, so every split of a long digit
597
+ * run is retried against the rest of the pattern and a failing token costs
598
+ * O(n^2). Disjoint arms fail each backtrack step in constant time.
593
599
  * @param {string} token
594
600
  * @returns {number | null}
595
601
  */
596
602
  function rgbChannel(token) {
597
- const pct = token.match(/^\+?(\d*\.?\d+)%$/);
603
+ const pct = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)%$/);
598
604
  if (pct) return (Math.min(100, parseFloat(pct[1])) / 100) * 255;
599
- const num = token.match(/^\+?(\d*\.?\d+)$/);
605
+ const num = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)$/);
600
606
  if (num) return parseFloat(num[1]);
601
607
  return null;
602
608
  }
@@ -608,7 +614,9 @@ function rgbChannel(token) {
608
614
  * @returns {number | null}
609
615
  */
610
616
  function hueDegrees(token) {
611
- const match = token.match(/^([+-]?\d*\.?\d+)(deg|grad|rad|turn)?$/);
617
+ const match = token.match(
618
+ /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))(deg|grad|rad|turn)?$/,
619
+ );
612
620
  if (!match) return null;
613
621
  const value = parseFloat(match[1]);
614
622
  const unit = match[2] || "deg";
@@ -630,7 +638,7 @@ function hueDegrees(token) {
630
638
  * @returns {number | null}
631
639
  */
632
640
  function hslPercent(token) {
633
- const match = token.match(/^\+?(\d*\.?\d+)%?$/);
641
+ const match = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)%?$/);
634
642
  return match ? Math.min(100, parseFloat(match[1])) : null;
635
643
  }
636
644
 
@@ -688,7 +696,11 @@ function canonicalizeColorFunction(value) {
688
696
  }
689
697
  // A literal-zero alpha is fully transparent — bare number (`0`, `0.0`) or the
690
698
  // CSS Color 4 percentage form (`0%`), which a browser also renders invisible.
691
- if (alpha !== null && /^\+?0*\.?0+%?$/.test(alpha)) return "transparent";
699
+ // Disjoint arms for the same reason the channel patterns above carry them:
700
+ // `0*` and `0+` both match a zero, so a long run of them would be retried at
701
+ // every split before the match could fail.
702
+ if (alpha !== null && /^\+?(?:0+(?:\.0+)?|\.0+)%?$/.test(alpha))
703
+ return "transparent";
692
704
  if (parts.length !== 3) return null;
693
705
  if (isRgb) {
694
706
  const channels = parts.map(rgbChannel);
@@ -1806,7 +1818,11 @@ const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
1806
1818
  // html-property "second pass changes nothing"). An open/end tag requires a
1807
1819
  // name letter after the `<`/`</`, so literal prose like `a < b` or an `i <3 u`
1808
1820
  // emoticon is not mistaken for markup.
1809
- const UNTERMINATED_MARKUP_TAIL_RE = /<(?:[!?]|\/?[a-zA-Z])[^>]*$/;
1821
+ // Matched against the slice after the last `>` rather than as a `[^>]*$` tail:
1822
+ // the tail form has to rescan to end-of-input from every `<` in the source, so
1823
+ // a document of many `<` and no `>` costs O(n^2). This form is fixed-length, so
1824
+ // each start offset is constant.
1825
+ const MARKUP_OPENER_RE = /<(?:[!?]|\/?[a-zA-Z])/;
1810
1826
 
1811
1827
  /**
1812
1828
  * Fold a raw source slice into the "inside an unterminated tag" state. A `>`
@@ -1820,8 +1836,10 @@ const UNTERMINATED_MARKUP_TAIL_RE = /<(?:[!?]|\/?[a-zA-Z])[^>]*$/;
1820
1836
  * @returns {boolean}
1821
1837
  */
1822
1838
  function foldAbsorb(absorbing, raw) {
1823
- if (raw.includes(">")) return UNTERMINATED_MARKUP_TAIL_RE.test(raw);
1824
- return absorbing || UNTERMINATED_MARKUP_TAIL_RE.test(raw);
1839
+ const lastClose = raw.lastIndexOf(">");
1840
+ const opensMarkup = MARKUP_OPENER_RE.test(raw.slice(lastClose + 1));
1841
+ if (lastClose !== -1) return opensMarkup;
1842
+ return absorbing || opensMarkup;
1825
1843
  }
1826
1844
 
1827
1845
  /**
@@ -2499,7 +2517,22 @@ const DIGEST_HEX_LENGTHS = new Set([32, 40, 56, 64, 96, 128]);
2499
2517
  // the slug benign (no uppercase) while catching the scattered-separator blob the
2500
2518
  // run gate missed. Anchored to the whole value for the same RAW-query reason.
2501
2519
  const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40,}={0,2}$/;
2502
- const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
2520
+
2521
+ /**
2522
+ * True when `value` carries at least one uppercase letter AND one digit — the
2523
+ * character mix that separates bulk-encoded bytes from a lowercase word-slug.
2524
+ * Two single-character-class scans rather than one `(?=.*[A-Z])(?=.*[0-9])`
2525
+ * regex: an unanchored lookahead pair costs a full scan at EVERY start offset,
2526
+ * so a long value that carries neither class is quadratic to reject. The two
2527
+ * agree on any value without a newline, which every caller guarantees by
2528
+ * gating on an anchored pattern over a newline-free alphabet first — `.` in
2529
+ * that lookahead does not cross a line, so only there could they differ.
2530
+ * @param {string} value
2531
+ * @returns {boolean}
2532
+ */
2533
+ function hasUpperAndDigit(value) {
2534
+ return /[A-Z]/.test(value) && /[0-9]/.test(value);
2535
+ }
2503
2536
 
2504
2537
  // A path segment whose whole value is a base64/hex run longer than any standard
2505
2538
  // content hash (SHA-512 hex is 128, base64 88; SHA-256 hex 64) is bulk encoded
@@ -2510,6 +2543,8 @@ const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
2510
2543
  // standard arm so a long word-slug (`the-secret-history-of-…`) is not mistaken
2511
2544
  // for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
2512
2545
  // above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
2546
+ // The two arms overlap on `[A-Fa-f0-9]` but stay linear: `={0,2}$` fails in
2547
+ // constant time after the first giveback, so each arm costs one pass.
2513
2548
  const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
2514
2549
  // SHA-512 is 128 hex characters and no standard digest is longer, so a run past
2515
2550
  // this is bulk encoded data rather than any fingerprint. Shared by the two path
@@ -2526,7 +2561,7 @@ const DIGEST_MAX_LEN = 128;
2526
2561
  * @returns {boolean}
2527
2562
  */
2528
2563
  function isBase64UrlBlob(value) {
2529
- return BLOB_VALUE_B64URL_RE.test(value) && B64URL_MIXED_RE.test(value);
2564
+ return BLOB_VALUE_B64URL_RE.test(value) && hasUpperAndDigit(value);
2530
2565
  }
2531
2566
 
2532
2567
  // `.` and `,` are legal in a URL but sit outside every blob alphabet above, so
@@ -2570,7 +2605,7 @@ function isChunkedPathBlob(segment) {
2570
2605
  return (
2571
2606
  joined !== null &&
2572
2607
  joined.length > DIGEST_MAX_LEN &&
2573
- ((PATH_BLOB_RE.test(joined) && B64URL_MIXED_RE.test(joined)) ||
2608
+ ((PATH_BLOB_RE.test(joined) && hasUpperAndDigit(joined)) ||
2574
2609
  isBase64UrlBlob(joined))
2575
2610
  );
2576
2611
  }
@@ -2968,7 +3003,12 @@ function parseSrcset(value) {
2968
3003
  const start = i;
2969
3004
  while (i < n && !SRCSET_WS_RE.test(value[i])) i++;
2970
3005
  const run = value.slice(start, i);
2971
- const url = run.replace(/,+$/, "");
3006
+ // The trailing commas come off by scanning back rather than by replacing
3007
+ // `,+$`: that pattern is unanchored at the start, so a run of commas costs
3008
+ // one match attempt per comma.
3009
+ let cut = run.length;
3010
+ while (cut > 0 && run[cut - 1] === ",") cut--;
3011
+ const url = run.slice(0, cut);
2972
3012
  if (url) urls.push(url);
2973
3013
  // A URL run ending in a comma is a bare candidate (no descriptor); the
2974
3014
  // comma already delimits the next one, so skip descriptor parsing.