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 +1 -1
- package/src/gates.mjs +14 -0
- package/src/index.mjs +17 -33
- package/src/invisible.mjs +27 -51
- package/src/joining-type.mjs +71 -2
- package/src/output.mjs +271 -188
- package/src/rehydrate.mjs +12 -9
- package/src/view-map.mjs +107 -7
- package/src/warnings.mjs +87 -0
- package/types/gates.d.mts +11 -0
- package/types/invisible.d.mts +1 -2
- package/types/joining-type.d.mts +12 -2
- package/types/output.d.mts +20 -26
- package/types/view-map.d.mts +59 -43
- package/types/warnings.d.mts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.19.
|
|
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(
|
|
99
|
+
warnings.push(LONE_SURROGATE_WARNING);
|
|
112
100
|
}
|
|
113
101
|
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
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
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/joining-type.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|