agent-sanitizer 2.19.6 → 2.19.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.19.6",
3
+ "version": "2.19.8",
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/gates.mjs CHANGED
@@ -28,6 +28,20 @@ export const HTML_TAG_PRESENT = /<[a-zA-Z/!?][^<>]*>/;
28
28
  */
29
29
  export const MD_LINK_HINT = /\]\(|!\[|^[ \t]*\[[^[\]\n]+\]:\s/m;
30
30
 
31
+ /**
32
+ * True when `text` is worth handing to the heavy remark/rehype graph at all:
33
+ * Layers 2 and 3 can only find something in text that carries an HTML tag or a
34
+ * markdown link. THE pre-gate for both entry points that run those layers
35
+ * (`sanitize()` in ./index.mjs, `sanitizeText()` in ./output.mjs) — it lives
36
+ * here, next to the two regexes it composes, so the two cannot gate on
37
+ * different conditions and pay (or skip) the ~200ms import for different inputs.
38
+ * @param {string} text
39
+ * @returns {boolean}
40
+ */
41
+ export function needsMarkdownPipeline(text) {
42
+ return HTML_TAG_PRESENT.test(text) || MD_LINK_HINT.test(text);
43
+ }
44
+
31
45
  // ─── Secret-shape pre-gate (Layer 3 URL-param reuse) ─────────────────────────
32
46
  // Cheap shape match that decides whether a URL parameter value carries a
33
47
  // credential (Layer 3). This hand-duplicates credential-shape knowledge that
package/src/index.mjs CHANGED
@@ -11,7 +11,14 @@
11
11
  * the convenience wrapper.
12
12
  */
13
13
  import { CATEGORY, describeStripped } from "./invisible.mjs";
14
+ import { needsMarkdownPipeline } from "./gates.mjs";
14
15
  import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
16
+ import {
17
+ describeExfil,
18
+ describeHtmlSanitized,
19
+ describeWarned,
20
+ LONE_SURROGATE_WARNING,
21
+ } from "./warnings.mjs";
15
22
 
16
23
  // Layer 1 lives in the zero-dependency `./layer1.mjs`, shared verbatim with the
17
24
  // tool-output pipeline (`./output`) and the Edit-repair rehydrator
@@ -48,25 +55,6 @@ export {
48
55
  matchesSecretHint,
49
56
  } from "./gates.mjs";
50
57
 
51
- /** @param {{ comments: number, hidden: number }} removed */
52
- function describeRemoved(removed) {
53
- const parts = [];
54
- if (removed.comments > 0) parts.push(`${removed.comments} HTML comment(s)`);
55
- if (removed.hidden > 0) parts.push(`${removed.hidden} hidden element(s)`);
56
- return parts.join(", ");
57
- }
58
-
59
- /** @param {{ tags: Record<string, number>, dataSrc: number }} warned */
60
- function describeWarned(warned) {
61
- const parts = Object.entries(warned.tags).map(
62
- ([tag, count]) => `${tag}×${count}`,
63
- );
64
- if (warned.dataSrc > 0) parts.push(`data: URI×${warned.dataSrc}`);
65
- return parts.length > 0
66
- ? `Preserved but reported (page source kept inspectable): ${parts.join(", ")}`
67
- : "";
68
- }
69
-
70
58
  /**
71
59
  * Sanitize untrusted text before any LLM sees it.
72
60
  *
@@ -108,10 +96,16 @@ export async function sanitize(text, options) {
108
96
  if (wellFormed !== cleaned) {
109
97
  cleaned = wellFormed;
110
98
  found.push(CATEGORY.LONE_SURROGATES);
111
- warnings.push("Normalized lone UTF-16 surrogates");
99
+ warnings.push(LONE_SURROGATE_WARNING);
112
100
  }
113
101
 
114
- if (!html) return { cleaned, found, warnings };
102
+ // Layers 2 and 3 can only find something in text carrying an HTML tag or a
103
+ // markdown link, so the shared pre-gate decides whether the heavy
104
+ // remark/rehype graph is imported at all — the same gate `sanitizeText()`
105
+ // applies, so both entry points pay for (and skip) the import on exactly the
106
+ // same inputs.
107
+ if (!html || !needsMarkdownPipeline(cleaned))
108
+ return { cleaned, found, warnings };
115
109
 
116
110
  let sanitizeHtml, detectExfil;
117
111
  /* c8 ignore start -- a rejected dynamic import of a module that ships in
@@ -140,9 +134,7 @@ export async function sanitize(text, options) {
140
134
  cleaned = layer2.text;
141
135
  if (layer2.removed.comments > 0) found.push(CATEGORY.HTML_COMMENTS);
142
136
  if (layer2.removed.hidden > 0) found.push(CATEGORY.HIDDEN_HTML);
143
- warnings.push(
144
- `HTML sanitized: ${describeRemoved(layer2.removed)} replaced with placeholders`,
145
- );
137
+ warnings.push(describeHtmlSanitized(layer2.removed));
146
138
  }
147
139
  const preserved = describeWarned(layer2.warned);
148
140
  if (preserved) warnings.push(preserved);
@@ -151,15 +143,7 @@ export async function sanitize(text, options) {
151
143
  const threats = detectExfil(preSplice);
152
144
  if (threats) {
153
145
  found.push(CATEGORY.EXFIL_URLS);
154
- const reasons = [
155
- ...new Set(
156
- threats.map(
157
- (threat) =>
158
- `${threat.isImage ? "image" : "link"} to ${threat.target}: ${threat.reason}`,
159
- ),
160
- ),
161
- ];
162
- warnings.push(`Exfil-shaped URLs detected: ${reasons.join("; ")}`);
146
+ warnings.push(describeExfil(threats));
163
147
  }
164
148
 
165
149
  return { cleaned, found, warnings };
package/src/invisible.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * sibling data module, not a package), so it decides preservation from the
10
10
  * actual cursive-join semantics rather than a hand-rolled script guess.
11
11
  */
12
- import { joiningType, isVirama } from "./joining-type.mjs";
12
+ import { joiningType, isVirama, isBrahmicConsonant } from "./joining-type.mjs";
13
13
  import { isStandardizedVariant } from "./standardized-variants.mjs";
14
14
  import { CF_CODEPOINTS } from "./cf-charset.mjs";
15
15
  import { scanAnsi, TOKEN_KIND } from "./ansi.mjs";
@@ -364,52 +364,32 @@ function isCjkIdeograph(ch) {
364
364
  return CJK_IDEOGRAPH_RE.test(ch);
365
365
  }
366
366
 
367
- // Consonant (KA..HA and script-specific additional-consonant) ranges of the
368
- // Brahmic scripts the joiner carve-out serves. A virama does half-form/conjunct
369
- // work ONLY on a consonant base; a bare or base-less halant + ZWJ carries no
370
- // rendering and is a smuggling channel, so the Indic joiner is preserved only
371
- // when its virama sits on one of these. Broad per-block spans — precision here
372
- // only needs "a real Brahmic letter of this script", not an exact consonant set.
367
+ // Brahmic consonants: the only base a virama does half-form/conjunct work on.
368
+ // A bare or base-less halant + ZWJ carries no rendering and is a smuggling
369
+ // channel, so the Indic joiner is preserved only over one of these.
373
370
  //
374
- // UNLIKE the CJK and Hangul gates these CANNOT be derived from a property
375
- // escape: the UCD property that names them is Indic_Syllabic_Category=Consonant,
376
- // and JS RegExp exposes only General_Category, Script, Script_Extensions and the
377
- // binary properties. \p{Script=Devanagari} is the wrong shape it also holds the
378
- // independent vowels (U+0904–U+0914), which a virama never attaches to, so
379
- // switching to it would preserve joiners after a bare vowel + halant. The table
380
- // therefore stays literal, and
381
- // test/invisible-unicode-tables.test.mjs pins the strongest contract that IS
382
- // derivable: every assigned code point in each span is a letter of the script
383
- // the span names, and each span holds at least one such letter. Drift (a future Unicode
384
- // filling a hole with a non-letter, or a typo'd span crossing into a neighbouring
385
- // script's block) then fails CI instead of shipping.
386
- // Keyed by script name so the contract test can check each span against the
387
- // script it claims; exported for exactly that test.
388
- /** @type {ReadonlyArray<readonly [string, number, number]>} */
389
- export const BRAHMIC_CONSONANT_RANGES = [
390
- ["Devanagari", 0x0915, 0x0939], // KA–HA
391
- ["Devanagari", 0x0958, 0x095f], // additional consonants
392
- ["Bengali", 0x0995, 0x09b9],
393
- ["Bengali", 0x09dc, 0x09df], // additional consonants
394
- ["Gurmukhi", 0x0a15, 0x0a39],
395
- ["Gurmukhi", 0x0a59, 0x0a5e], // additional consonants
396
- ["Gujarati", 0x0a95, 0x0ab9],
397
- ["Oriya", 0x0b15, 0x0b39],
398
- ["Oriya", 0x0b5c, 0x0b5f], // additional consonants
399
- ["Tamil", 0x0b95, 0x0bb9],
400
- ["Telugu", 0x0c15, 0x0c39],
401
- ["Telugu", 0x0c58, 0x0c5a], // additional consonants
402
- ["Kannada", 0x0c95, 0x0cb9],
403
- ["Malayalam", 0x0d15, 0x0d3a],
404
- ["Sinhala", 0x0d9a, 0x0dc6],
405
- ];
406
-
407
- /** True when `cp` is a Brahmic consonant — the only base a virama attaches to.
408
- * @param {number} cp @returns {boolean} */
409
- function isBrahmicConsonant(cp) {
410
- for (const [, start, end] of BRAHMIC_CONSONANT_RANGES)
411
- if (cp >= start && cp <= end) return true;
412
- return false;
371
+ // The spans are GENERATED from the UCD (Indic_Syllabic_Category=Consonant,
372
+ // restricted by Script) and live in ./joining-type.mjs alongside the virama
373
+ // table they are read against see scripts/gen-joining-type.mjs. They used to
374
+ // be hand-typed per-block KA–HA approximations here, which swept up the holes
375
+ // between the real consonants; ECMAScript exposes no
376
+ // \p{Indic_Syllabic_Category=…} escape, and \p{Script=Devanagari} is the wrong
377
+ // shape on its own (it also holds the independent vowels U+0904–U+0914, which a
378
+ // virama never attaches to), so a generated table is the only drift-proof
379
+ // answer. Re-exported here because it was part of this module's surface before
380
+ // it moved, and because test/invisible-unicode-tables.test.mjs checks each span
381
+ // against the script it claims.
382
+ export { BRAHMIC_CONSONANT_RANGES } from "./joining-type.mjs";
383
+
384
+ /** True when `ch` is a Brahmic consonant. Takes a CHARACTER, like its sibling
385
+ * predicates here (`isCjkIdeograph`, `isJoinControl`), over the code-point
386
+ * `isBrahmicConsonant` it wraps in ./joining-type.mjs, where every predicate
387
+ * takes a code point.
388
+ * @param {string} ch @returns {boolean} */
389
+ function isBrahmicConsonantChar(ch) {
390
+ return (
391
+ ch !== "" && isBrahmicConsonant(/** @type {number} */ (ch.codePointAt(0)))
392
+ );
413
393
  }
414
394
 
415
395
  // ─── Blank-filler carve-out (Braille / archaic Hangul) ───────────────────────
@@ -532,11 +512,7 @@ function followsBrahmicConjunct(cps, i) {
532
512
  while (j >= 0 && !isJoinControl(cps[j]) && classify(cps[j]) !== null) j--;
533
513
  if (j < 0 || !isVirama(/** @type {number} */ (cps[j].codePointAt(0))))
534
514
  return false;
535
- const base = effectiveNeighbor(cps, j, -1);
536
- return (
537
- base !== "" &&
538
- isBrahmicConsonant(/** @type {number} */ (base.codePointAt(0)))
539
- );
515
+ return isBrahmicConsonantChar(effectiveNeighbor(cps, j, -1));
540
516
  }
541
517
 
542
518
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * GENERATED by scripts/gen-joining-type.mjs from ucd-full@17.0.0 — DO NOT EDIT.
3
3
  *
4
- * Unicode Joining_Type and Indic virama range tables backing the ZWNJ/ZWJ
5
- * carve-out in invisible.mjs. Regenerate with `pnpm gen:joining-type`;
4
+ * Unicode Joining_Type, Indic virama and Brahmic consonant range tables backing
5
+ * the ZWNJ/ZWJ carve-out in invisible.mjs. Regenerate with `pnpm gen:joining-type`;
6
6
  * test/joining-type.test.mjs fails if this drifts from the pinned UCD.
7
7
  */
8
8
 
@@ -573,6 +573,62 @@ const VIRAMA_RANGES = [
573
573
  [0x119e0, 0x119e0],
574
574
  [0x11c3f, 0x11c3f],
575
575
  ];
576
+
577
+ // [start, end, Script] Indic_Syllabic_Category = Consonant ranges restricted to
578
+ // the Brahmic scripts the joiner carve-out covers — the only bases a virama
579
+ // attaches to. Keyed by script name so the contract test can check each span
580
+ // against the script it claims.
581
+ /** @type {ReadonlyArray<readonly [string, number, number]>} */
582
+ export const BRAHMIC_CONSONANT_RANGES = [
583
+ ["Devanagari", 0x0915, 0x0939],
584
+ ["Devanagari", 0x0958, 0x095f],
585
+ ["Devanagari", 0x0978, 0x097f],
586
+ ["Bengali", 0x0995, 0x09a8],
587
+ ["Bengali", 0x09aa, 0x09b0],
588
+ ["Bengali", 0x09b2, 0x09b2],
589
+ ["Bengali", 0x09b6, 0x09b9],
590
+ ["Bengali", 0x09dc, 0x09dd],
591
+ ["Bengali", 0x09df, 0x09df],
592
+ ["Bengali", 0x09f0, 0x09f1],
593
+ ["Gurmukhi", 0x0a15, 0x0a28],
594
+ ["Gurmukhi", 0x0a2a, 0x0a30],
595
+ ["Gurmukhi", 0x0a32, 0x0a33],
596
+ ["Gurmukhi", 0x0a35, 0x0a36],
597
+ ["Gurmukhi", 0x0a38, 0x0a39],
598
+ ["Gurmukhi", 0x0a59, 0x0a5c],
599
+ ["Gurmukhi", 0x0a5e, 0x0a5e],
600
+ ["Gujarati", 0x0a95, 0x0aa8],
601
+ ["Gujarati", 0x0aaa, 0x0ab0],
602
+ ["Gujarati", 0x0ab2, 0x0ab3],
603
+ ["Gujarati", 0x0ab5, 0x0ab9],
604
+ ["Gujarati", 0x0af9, 0x0af9],
605
+ ["Oriya", 0x0b15, 0x0b28],
606
+ ["Oriya", 0x0b2a, 0x0b30],
607
+ ["Oriya", 0x0b32, 0x0b33],
608
+ ["Oriya", 0x0b35, 0x0b39],
609
+ ["Oriya", 0x0b5c, 0x0b5d],
610
+ ["Oriya", 0x0b5f, 0x0b5f],
611
+ ["Oriya", 0x0b71, 0x0b71],
612
+ ["Tamil", 0x0b95, 0x0b95],
613
+ ["Tamil", 0x0b99, 0x0b9a],
614
+ ["Tamil", 0x0b9c, 0x0b9c],
615
+ ["Tamil", 0x0b9e, 0x0b9f],
616
+ ["Tamil", 0x0ba3, 0x0ba4],
617
+ ["Tamil", 0x0ba8, 0x0baa],
618
+ ["Tamil", 0x0bae, 0x0bb9],
619
+ ["Telugu", 0x0c15, 0x0c28],
620
+ ["Telugu", 0x0c2a, 0x0c39],
621
+ ["Telugu", 0x0c58, 0x0c5a],
622
+ ["Kannada", 0x0c95, 0x0ca8],
623
+ ["Kannada", 0x0caa, 0x0cb3],
624
+ ["Kannada", 0x0cb5, 0x0cb9],
625
+ ["Kannada", 0x0cde, 0x0cde],
626
+ ["Malayalam", 0x0d15, 0x0d3a],
627
+ ["Sinhala", 0x0d9a, 0x0db1],
628
+ ["Sinhala", 0x0db3, 0x0dbb],
629
+ ["Sinhala", 0x0dbd, 0x0dbd],
630
+ ["Sinhala", 0x0dc0, 0x0dc6],
631
+ ];
576
632
  // Stryker restore all
577
633
 
578
634
  /**
@@ -614,3 +670,16 @@ export function joiningType(cp) {
614
670
  export function isVirama(cp) {
615
671
  return /** @type {boolean} */ (lookup(VIRAMA_RANGES, cp, false));
616
672
  }
673
+
674
+ /**
675
+ * True when `cp` is a Brahmic consonant — the only base a virama attaches to,
676
+ * and therefore the only base after which a ZWJ/ZWNJ is a real conjunct
677
+ * request rather than a zero-width payload.
678
+ * @param {number} cp
679
+ * @returns {boolean}
680
+ */
681
+ export function isBrahmicConsonant(cp) {
682
+ for (const [, start, end] of BRAHMIC_CONSONANT_RANGES)
683
+ if (cp >= start && cp <= end) return true;
684
+ return false;
685
+ }