agent-sanitizer 2.34.1 → 2.34.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/invisible.mjs +133 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.34.1",
3
+ "version": "2.34.3",
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/invisible.mjs CHANGED
@@ -468,13 +468,38 @@ function isHangul(ch) {
468
468
  return HANGUL_RE.test(ch);
469
469
  }
470
470
 
471
- // Non-global single-char classifiers (CHECKS carry `g`, whose lastIndex is
472
- // stateful across `.test`). carveStrip uses these to attribute each removed
473
- // char to its CHECKS category so `found` names exactly what was stripped.
474
- const CHECK_ONE = CHECKS.map(
475
- ([code, re]) =>
476
- /** @type {[string, RegExp]} */ ([code, new RegExp(re.source, "u")]),
477
- );
471
+ // code point -> CHECKS category, projected from the SAME three code-point sets
472
+ // the CHECKS regexes are built from, in CHECKS order so a code point in two sets
473
+ // gets the category the regexes would report first. Every CHECKS class holds
474
+ // single code points under the `u` flag, so a lookup and a `.test` answer
475
+ // identically; test/invisible-fast-path.test.mjs pins that over the whole
476
+ // code-point space, because a code point dropped here is one the scatter gate
477
+ // stops counting. A lookup rather than the regexes because this runs once per
478
+ // code point of every prompt and tool output.
479
+ /** @type {Map<number, string>} */
480
+ const TRACKED_INVISIBLE = new Map();
481
+ for (const [code, codepoints] of /** @type {[string, Iterable<number>][]} */ ([
482
+ [CATEGORY.CF, CF_CODEPOINTS],
483
+ [CATEGORY.VARIATION_SELECTORS, [...VS].map((ch) => ch.codePointAt(0))],
484
+ [CATEGORY.BLANK_FILLERS, [...BLANK_NON_CF].map((ch) => ch.codePointAt(0))],
485
+ ]))
486
+ for (const cp of codepoints)
487
+ if (!TRACKED_INVISIBLE.has(cp)) TRACKED_INVISIBLE.set(cp, code);
488
+
489
+ // No tracked code point sits below this, so ASCII and Latin-1 text answers the
490
+ // classifier without touching the map at all.
491
+ const MIN_TRACKED_CP = Math.min(...TRACKED_INVISIBLE.keys());
492
+
493
+ /**
494
+ * The CHECKS category code (a CATEGORY value) a code point belongs to, or null
495
+ * when it is not payload-capable (an ordinary visible character).
496
+ * @param {number} cp
497
+ * @returns {string | null}
498
+ */
499
+ function classifyCp(cp) {
500
+ if (cp < MIN_TRACKED_CP) return null;
501
+ return TRACKED_INVISIBLE.get(cp) ?? null;
502
+ }
478
503
 
479
504
  /**
480
505
  * The CHECKS category code (a CATEGORY value) a single code point belongs to,
@@ -483,8 +508,44 @@ const CHECK_ONE = CHECKS.map(
483
508
  * @returns {string | null}
484
509
  */
485
510
  function classify(ch) {
486
- for (const [code, re] of CHECK_ONE) if (re.test(ch)) return code;
487
- return null;
511
+ return classifyCp(/** @type {number} */ (ch.codePointAt(0)));
512
+ }
513
+
514
+ /**
515
+ * True when `text` holds at least one code point {@link classify} calls
516
+ * invisible. Walks UTF-16 units directly rather than iterating code-point
517
+ * strings, so a clean multi-MB paste is answered without allocating one string
518
+ * per character; surrogates are paired exactly as the String iterator pairs
519
+ * them, an unpaired one classifying as the lone surrogate it is.
520
+ * @param {string} text
521
+ * @returns {boolean}
522
+ */
523
+ function hasInvisibleCodePoint(text) {
524
+ for (let i = 0; i < text.length; i++) {
525
+ let cp = text.charCodeAt(i);
526
+ if (cp >= 0xd800 && cp <= 0xdbff && i + 1 < text.length) {
527
+ const low = text.charCodeAt(i + 1);
528
+ if (low >= 0xdc00 && low <= 0xdfff) {
529
+ cp = (cp - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000;
530
+ i++;
531
+ }
532
+ }
533
+ if (classifyCp(cp) !== null) return true;
534
+ }
535
+ return false;
536
+ }
537
+
538
+ /**
539
+ * The number of code points in `text`, counted by the String iterator itself —
540
+ * the same unit `Array.from(text).length` measures, without the array.
541
+ * @param {string} text
542
+ * @returns {number}
543
+ */
544
+ function codePointLength(text) {
545
+ let n = 0;
546
+ const iterator = text[Symbol.iterator]();
547
+ while (!iterator.next().done) n++;
548
+ return n;
488
549
  }
489
550
 
490
551
  /** A cursive-joining letter: Joining_Type dual, right, or left. (C is a join
@@ -621,18 +682,36 @@ function isEmojiPresentationSelector(cps, i) {
621
682
  * @returns {{ codes: (string|null)[], kind: (string|null)[], payloadInvis: number, visibleLen: number }}
622
683
  */
623
684
  function analyzeCarve(cps) {
624
- const codes = cps.map(classify);
625
- const tagKeep = markTagSequences(cps);
626
- const kind = cps.map((_, i) => {
627
- if (codes[i] === null) return null;
628
- if (tagKeep[i]) return "tag";
629
- if (isPreservedJoiner(cps, i)) return "joiner";
630
- if (isEmojiPresentationSelector(cps, i)) return "emojivs";
631
- if (isStandardizedVariationSelector(cps, i)) return "stdvs";
632
- if (isIdeographicVariationSelector(cps, i)) return "ivs";
633
- if (isPreservedBlankFiller(cps, i)) return "blank";
634
- return null;
635
- });
685
+ const codes = new Array(cps.length);
686
+ // Indices of the invisibles, so every pass below is O(invisibles) rather than
687
+ // O(length): in ordinary text they are a vanishing fraction of a paste, and a
688
+ // full-length pass per preserve rule is what made the analysis linear in the
689
+ // WHOLE prompt several times over.
690
+ const invisible = [];
691
+ let visibleLen = 0;
692
+ let hasTagBase = false;
693
+ for (let i = 0; i < cps.length; i++) {
694
+ const code = classify(cps[i]);
695
+ codes[i] = code;
696
+ if (code !== null) {
697
+ invisible.push(i);
698
+ continue;
699
+ }
700
+ visibleLen++;
701
+ hasTagBase ||= cps[i].codePointAt(0) === TAG_BASE;
702
+ }
703
+ // A tag sequence is preservable only when it opens on a TAG_BASE pictograph,
704
+ // so with no base in the text markTagSequences returns all-false.
705
+ const tagKeep = hasTagBase ? markTagSequences(cps) : null;
706
+ const kind = new Array(cps.length).fill(null);
707
+ for (const i of invisible) {
708
+ if (tagKeep !== null && tagKeep[i]) kind[i] = "tag";
709
+ else if (isPreservedJoiner(cps, i)) kind[i] = "joiner";
710
+ else if (isEmojiPresentationSelector(cps, i)) kind[i] = "emojivs";
711
+ else if (isStandardizedVariationSelector(cps, i)) kind[i] = "stdvs";
712
+ else if (isIdeographicVariationSelector(cps, i)) kind[i] = "ivs";
713
+ else if (isPreservedBlankFiller(cps, i)) kind[i] = "blank";
714
+ }
636
715
  // Blank fillers are budgeted here, document-wide and all-or-nothing, against
637
716
  // the visible anchor-script text rather than against the joiner/selector
638
717
  // counter in carveStrip (see PRESERVED_BLANK_PER_ANCHOR for why the two
@@ -647,13 +726,13 @@ function analyzeCarve(cps) {
647
726
  // `⠃⠀⠃⠀…` alternation of 200 Braille blanks. The anchor scan is skipped below
648
727
  // the floor: it costs a script regex per visible character, and analyzeCarve
649
728
  // runs on every prompt and tool output.
650
- const blankScript = kind.map((k, i) =>
651
- k !== "blank"
652
- ? null
653
- : cps[i].codePointAt(0) === BRAILLE_BLANK
654
- ? "braille"
655
- : "hangul",
656
- );
729
+ /** @type {Record<string, number[]>} */
730
+ const blankIndices = { braille: [], hangul: [] };
731
+ for (const i of invisible)
732
+ if (kind[i] === "blank")
733
+ blankIndices[
734
+ cps[i].codePointAt(0) === BRAILLE_BLANK ? "braille" : "hangul"
735
+ ].push(i);
657
736
  for (const [
658
737
  script,
659
738
  isAnchor,
@@ -661,22 +740,17 @@ function analyzeCarve(cps) {
661
740
  ["braille", isBrailleCell],
662
741
  ["hangul", isHangul],
663
742
  ])) {
664
- const blanks = blankScript.filter((s) => s === script).length;
665
- if (blanks <= TOTAL_PRESERVED_BLANK_BUDGET) continue;
743
+ const indices = blankIndices[script];
744
+ if (indices.length <= TOTAL_PRESERVED_BLANK_BUDGET) continue;
666
745
  const anchors = cps.reduce(
667
746
  (n, ch, i) => n + (codes[i] === null && isAnchor(ch) ? 1 : 0),
668
747
  0,
669
748
  );
670
- if (blanks > Math.floor(anchors / PRESERVED_BLANK_PER_ANCHOR))
671
- for (let i = 0; i < kind.length; i++)
672
- if (blankScript[i] === script) kind[i] = null;
749
+ if (indices.length > Math.floor(anchors / PRESERVED_BLANK_PER_ANCHOR))
750
+ for (const i of indices) kind[i] = null;
673
751
  }
674
752
  let payloadInvis = 0;
675
- let visibleLen = 0;
676
- for (let i = 0; i < cps.length; i++) {
677
- if (codes[i] === null) visibleLen++;
678
- else if (kind[i] === null) payloadInvis++;
679
- }
753
+ for (const i of invisible) if (kind[i] === null) payloadInvis++;
680
754
  return { codes, kind, payloadInvis, visibleLen };
681
755
  }
682
756
 
@@ -690,6 +764,12 @@ function analyzeCarve(cps) {
690
764
  * @returns {number}
691
765
  */
692
766
  export function countPayloadInvisible(text) {
767
+ // Payload is a SUBSET of the invisibles (analyzeCarve counts only positions
768
+ // classify marks invisible), so a text with none has a payload count of zero
769
+ // and needs neither the code-point array nor the carve analysis. This reads
770
+ // every code point — it is a necessary condition checked in full, not a scan
771
+ // bound an attacker could paste past.
772
+ if (!hasInvisibleCodePoint(text)) return 0;
693
773
  return analyzeCarve(Array.from(text)).payloadInvis;
694
774
  }
695
775
 
@@ -853,7 +933,7 @@ function clusterEnds(body) {
853
933
  const ends = [];
854
934
  let end = 0;
855
935
  for (const { segment } of GRAPHEME_SEGMENTER.segment(body)) {
856
- end += Array.from(segment).length;
936
+ end += codePointLength(segment);
857
937
  ends.push(end);
858
938
  }
859
939
  return ends;
@@ -1067,6 +1147,13 @@ export function payloadInvisibleView(text) {
1067
1147
  * @returns {string | null}
1068
1148
  */
1069
1149
  export function payloadLongRunSample(text) {
1150
+ // The view is code-point-for-code-point with `text` and only ever REPLACES an
1151
+ // invisible with a space, so a run in the view is a run in `text`: no long run
1152
+ // 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
1154
+ // 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;
1070
1157
  LONG_RUN_RE.lastIndex = 0;
1071
1158
  return payloadInvisibleView(text).match(LONG_RUN_RE)?.[0] ?? null;
1072
1159
  }
@@ -1093,11 +1180,13 @@ export function payloadLongRunSample(text) {
1093
1180
  */
1094
1181
  export function countEffectiveInvisible(text) {
1095
1182
  const payload = countPayloadInvisible(text);
1096
- const surplusPreservedJoiners = Math.max(
1097
- 0,
1098
- [...text].length - [...stripInvisible(text)].length - payload,
1099
- );
1100
- return payload + surplusPreservedJoiners;
1183
+ const stripped = stripInvisible(text);
1184
+ // Two identical strings differ by zero code points, so the common clean paste
1185
+ // skips both counting passes; codePointLength is the String iterator's count,
1186
+ // the same unit `[...text].length` measures, without the array.
1187
+ const removed =
1188
+ stripped === text ? 0 : codePointLength(text) - codePointLength(stripped);
1189
+ return payload + Math.max(0, removed - payload);
1101
1190
  }
1102
1191
 
1103
1192
  /**