agent-sanitizer 2.23.1 → 2.23.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/THREAT-MODEL.md CHANGED
@@ -35,6 +35,22 @@ table](./README.md#entry-points) maps each to its import.
35
35
  Sinhala) or inside an emoji ZWJ sequence. The carve-out fires only when **both**
36
36
  neighbors clearly belong to the context, and it is disabled once the total
37
37
  invisible count crosses a scatter floor—over-stripping beats under-stripping.
38
+ - **Blank fillers doing real work in their own script**: the Braille blank
39
+ (U+2800) beside a real cell, a Hangul filler beside a real jamo/syllable. A
40
+ _run_ of fillers has only fillers for neighbors, so it fails the anchor and is
41
+ stripped. Because U+2800 _is_ the word space of Unicode Braille and a Hangul
42
+ filler completes a defective syllable, these are far denser in genuine text
43
+ than joiners are, so they carry their own document-wide allowance—one
44
+ preserved blank per two visible anchor-script characters, above a floor—rather
45
+ than drawing on the joiner/selector preserve budget. The allowance is counted
46
+ per script (a blank never anchors cross-script, so Korean prose must not fund
47
+ a Braille channel). Past that ratio no blank of that script is preserved
48
+ (never half-spaced) and all of them count as payload, which is the density an
49
+ alternating `syllable filler …` channel needs. Contracted (grade-2) Braille
50
+ sits closest to the boundary: alphabet wordsigns are single cells, so a
51
+ passage of mostly one-cell words approaches 1:1 and is stripped like the
52
+ channel—an accepted residual false positive inherent to a density rule, not a
53
+ gap, and not worth widening the ratio to reach.
38
54
 
39
55
  **Reassembly hardening.** The two passes feed each other in _both_ directions:
40
56
  stripping an invisible char can reconstitute an ANSI escape its split had
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.23.1",
3
+ "version": "2.23.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
@@ -228,9 +228,10 @@ export const CONSECUTIVE_JOINER_CAP = 8;
228
228
  // visible characters in a row), exactly like CONSECUTIVE_JOINER_CAP.
229
229
  export const CONSECUTIVE_SELECTOR_CAP = 8;
230
230
 
231
- // Floor on the document-wide preserve budget, shared by both preserve kinds
232
- // (see `kind` in analyzeCarve: joiners AND presentation selectors draw from
233
- // the same counter). The Joining_Type gate strips joiners that do no
231
+ // Floor on the document-wide preserve budget for joiners, selectors and tag
232
+ // sequences (see `kind` in analyzeCarve blank fillers are NOT charged here;
233
+ // they have their own allowance, see TOTAL_PRESERVED_BLANK_BUDGET). The
234
+ // Joining_Type gate strips joiners that do no
234
235
  // rendering work regardless of count, so the bulk covert channel (ZWNJ
235
236
  // scattered through Latin/ASCII/mixed text) is closed by shape, not by
236
237
  // counting. What remains is the residual channel of MEANINGFUL joiners/
@@ -260,6 +261,31 @@ export const PRESERVED_JOINER_PER_VISIBLE = 8;
260
261
  // text can widen.
261
262
  export const PRESERVE_HARD_CAP = 64;
262
263
 
264
+ // Floor on the document-wide allowance for PRESERVED blank fillers (the
265
+ // Braille blank and the Hangul fillers — see the blank-filler carve-out). Kept
266
+ // separate from the joiner/selector budget because the two have completely
267
+ // different legitimate densities, and short blank-dense strings (a one-line
268
+ // Braille phrase, a lone archaic syllable) must stay un-clipped.
269
+ export const TOTAL_PRESERVED_BLANK_BUDGET = 16;
270
+
271
+ // Visible ANCHOR-script code points (a non-blank Braille cell, a non-filler
272
+ // Hangul jamo/syllable) required per preserved blank filler above the floor. A
273
+ // blank is only ever preservable next to one of these, so this ratio is what
274
+ // separates real text from the degenerate channel: U+2800 separates WORDS and a
275
+ // filler completes a syllable, so genuine text spends several anchor characters
276
+ // per blank and stays under one blank per two anchors, while the alternation an
277
+ // attacker needs to stuff a bit per character (`가ᅟ가ᅟ…`, `⠃⠀⠃⠀…`) is exactly
278
+ // 1:1 and fails.
279
+ //
280
+ // Deliberately NOT capped by PRESERVE_HARD_CAP: an absolute ceiling is what
281
+ // truncated long Braille documents, and unlike the joiner channel this one
282
+ // cannot scale on invisible cover text — every additional bit costs the
283
+ // attacker two VISIBLE anchor-script characters, and the blanks themselves
284
+ // render as spacing a reader can see. Over the ratio, NO blank in the document
285
+ // is preserved (all-or-nothing, so a document never comes out half-spaced) and
286
+ // every one of them becomes payload — which then also feeds the scatter floor.
287
+ export const PRESERVED_BLANK_PER_ANCHOR = 2;
288
+
263
289
  // Scripts whose orthography uses ZWNJ/ZWJ between letters as a rendering
264
290
  // control. The runtime gate is now script-agnostic (it reads Joining_Type, so it
265
291
  // covers every cursive/Brahmic script, not just these), but this list remains
@@ -402,6 +428,17 @@ function isBrahmicConsonantChar(ch) {
402
428
  // the anchor and is stripped — the run-length gate falls out of the anchor. The
403
429
  // zero-width Mn marks in BLANK_NON_CF (U+034F/17B4/17B5) have no such benign
404
430
  // standalone use, so they are never preserved.
431
+ //
432
+ // Blank fillers are NOT charged against the joiner/selector preserve budget:
433
+ // that budget's density model is "~1 preserved invisible per 8 visible chars"
434
+ // (PRESERVED_JOINER_PER_VISIBLE), measured on Persian ZWNJ prose, with a fixed
435
+ // PRESERVE_HARD_CAP ceiling. Blanks are an order of magnitude denser in genuine
436
+ // text — U+2800 IS the word space of Unicode Braille, and a Hangul filler
437
+ // completes a defective syllable — so charging them there mangled real content:
438
+ // a 40-word Braille passage lost 14 of its 39 word spaces (words run together)
439
+ // and a 200-word one kept 64 of 199, with `found` reporting a strip on a
440
+ // perfectly legitimate document. They draw on the anchor-proportional allowance
441
+ // below instead (see TOTAL_PRESERVED_BLANK_BUDGET).
405
442
  const BRAILLE_BLANK = 0x2800;
406
443
  const HANGUL_FILLERS = new Set([0x115f, 0x1160, 0x3164, 0xffa0]);
407
444
  // Code points that trigger the carve-out path for blank fillers (see
@@ -600,6 +637,44 @@ function analyzeCarve(cps) {
600
637
  if (isPreservedBlankFiller(cps, i)) return "blank";
601
638
  return null;
602
639
  });
640
+ // Blank fillers are budgeted here, document-wide and all-or-nothing, against
641
+ // the visible anchor-script text rather than against the joiner/selector
642
+ // counter in carveStrip (see PRESERVED_BLANK_PER_ANCHOR for why the two
643
+ // cannot share a density model). Deciding it in analyzeCarve rather than in
644
+ // the emit loop keeps countPayloadInvisible and payloadInvisibleView honest:
645
+ // a blank the stripper will remove is payload to every consumer, so the
646
+ // prompt layer's scatter gate sees it without re-deriving the budget.
647
+ //
648
+ // Budgeted PER SCRIPT, because a blank never anchors cross-script: pooling the
649
+ // two anchor counts would let one script's cover text fund the other's
650
+ // channel, so 400 chars of ordinary Korean prose would buy an unreported
651
+ // `⠃⠀⠃⠀…` alternation of 200 Braille blanks. The anchor scan is skipped below
652
+ // the floor: it costs a script regex per visible character, and analyzeCarve
653
+ // runs on every prompt and tool output.
654
+ const blankScript = kind.map((k, i) =>
655
+ k !== "blank"
656
+ ? null
657
+ : cps[i].codePointAt(0) === BRAILLE_BLANK
658
+ ? "braille"
659
+ : "hangul",
660
+ );
661
+ for (const [
662
+ script,
663
+ isAnchor,
664
+ ] of /** @type {[string, (ch: string) => boolean][]} */ ([
665
+ ["braille", isBrailleCell],
666
+ ["hangul", isHangul],
667
+ ])) {
668
+ const blanks = blankScript.filter((s) => s === script).length;
669
+ if (blanks <= TOTAL_PRESERVED_BLANK_BUDGET) continue;
670
+ const anchors = cps.reduce(
671
+ (n, ch, i) => n + (codes[i] === null && isAnchor(ch) ? 1 : 0),
672
+ 0,
673
+ );
674
+ if (blanks > Math.floor(anchors / PRESERVED_BLANK_PER_ANCHOR))
675
+ for (let i = 0; i < kind.length; i++)
676
+ if (blankScript[i] === script) kind[i] = null;
677
+ }
603
678
  let payloadInvis = 0;
604
679
  let visibleLen = 0;
605
680
  for (let i = 0; i < cps.length; i++) {
@@ -790,12 +865,15 @@ function clusterEnds(body) {
790
865
 
791
866
  /**
792
867
  * Carve-out strip (an invisible the carve-out might preserve is present): walk
793
- * GRAPHEME CLUSTERS, preserving a cluster's joiners/selectors/tags/blank-fillers
794
- * only where each has its `kind` set AND the text stays under the scatter floor
795
- * AND the whole cluster fits inside the remaining per-run
796
- * (CONSECUTIVE_JOINER_CAP / CONSECUTIVE_SELECTOR_CAP) and document-wide
797
- * (TOTAL_PRESERVED_JOINER_BUDGET) preserve allowance — otherwise every
798
- * preservable char in that cluster is stripped like any other payload byte.
868
+ * GRAPHEME CLUSTERS, preserving a cluster's joiners/selectors/tags only where
869
+ * each has its `kind` set AND the text stays under the scatter floor AND the
870
+ * whole cluster fits inside the remaining per-run (CONSECUTIVE_JOINER_CAP /
871
+ * CONSECUTIVE_SELECTOR_CAP) and document-wide (TOTAL_PRESERVED_JOINER_BUDGET)
872
+ * preserve allowance — otherwise every preservable char in that cluster is
873
+ * stripped like any other payload byte. Blank fillers are the exception: their
874
+ * allowance is anchor-proportional and already spent document-wide in
875
+ * analyzeCarve (see PRESERVED_BLANK_PER_ANCHOR), so here they answer only to
876
+ * the scatter floor and do not draw on the joiner/selector budget.
799
877
  *
800
878
  * The budget is charged against the CLUSTER, not the code point, because the
801
879
  * cluster is the indivisible unit: charging per code point let a limit fall due
@@ -862,7 +940,10 @@ function carveStrip(body) {
862
940
  let joiners = 0;
863
941
  let selectors = 0;
864
942
  for (let k = start; k < end; k++) {
865
- if (kind[k] === null) continue;
943
+ // "blank" is exempt: analyzeCarve already decided it against the
944
+ // anchor-proportional allowance, so it neither draws on this budget nor
945
+ // is stripped by it (only by the scatter floor, via allowCarveOut).
946
+ if (kind[k] === null || kind[k] === "blank") continue;
866
947
  need++;
867
948
  if (kind[k] === "joiner") joiners++;
868
949
  if (kind[k] === "ivs" || kind[k] === "stdvs") selectors++;
@@ -904,11 +985,13 @@ function carveStrip(body) {
904
985
  out += cps[k]; // ordinary visible character
905
986
  continue;
906
987
  }
907
- if (fits && kind[k] !== null) {
988
+ // A blank filler rides on allowCarveOut alone (its own allowance is
989
+ // already spent in analyzeCarve); everything else rides on `fits`.
990
+ if (kind[k] === "blank" ? allowCarveOut : fits && kind[k] !== null) {
908
991
  if (kind[k] === "joiner") joinerRun++;
909
992
  if (kind[k] === "ivs" || kind[k] === "stdvs") selectorRun++;
910
- preservedTotal++;
911
- prevVisible = false; // a joiner/selector/tag keeps the cluster open
993
+ if (kind[k] !== "blank") preservedTotal++;
994
+ prevVisible = false; // a joiner/selector/tag/blank keeps the cluster open
912
995
  out += cps[k];
913
996
  continue;
914
997
  }
@@ -102,5 +102,7 @@ export const CONSECUTIVE_SELECTOR_CAP: 8;
102
102
  export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
103
103
  export const PRESERVED_JOINER_PER_VISIBLE: 8;
104
104
  export const PRESERVE_HARD_CAP: 64;
105
+ export const TOTAL_PRESERVED_BLANK_BUDGET: 16;
106
+ export const PRESERVED_BLANK_PER_ANCHOR: 2;
105
107
  export const LINGUISTIC_SCRIPTS: string[];
106
108
  export { BRAHMIC_CONSONANT_RANGES } from "./joining-type.mjs";