agent-sanitizer 2.34.1 → 2.34.2
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/package.json +1 -1
- 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.
|
|
3
|
+
"version": "2.34.2",
|
|
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
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
665
|
-
if (
|
|
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 (
|
|
671
|
-
for (
|
|
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
|
-
|
|
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 +=
|
|
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
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
/**
|