agent-sanitizer 2.23.0 → 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/SECURITY.md +17 -0
- package/THREAT-MODEL.md +16 -0
- package/package.json +1 -1
- package/src/invisible.mjs +96 -13
- package/types/invisible.d.mts +2 -0
package/SECURITY.md
CHANGED
|
@@ -24,6 +24,23 @@ that exposes a secret, or a crash on adversarial input are all in scope. Please
|
|
|
24
24
|
don't include real credentials in a report—a credential-shaped placeholder is
|
|
25
25
|
enough.
|
|
26
26
|
|
|
27
|
+
## Dependency supply chain
|
|
28
|
+
|
|
29
|
+
Dependency versions must age before this repo will resolve them:
|
|
30
|
+
`pnpm-workspace.yaml` sets `minimumReleaseAge` to 4320 minutes (3 days), so a
|
|
31
|
+
package published minutes ago cannot enter the lockfile. The npm compromises
|
|
32
|
+
this defends against are typically detected and unpublished within hours to a
|
|
33
|
+
couple of days, and the window is where that detection happens.
|
|
34
|
+
|
|
35
|
+
The setting gates resolution only — `pnpm install --frozen-lockfile` installs
|
|
36
|
+
what the lockfile already names — so it constrains `pnpm add` and `pnpm update`,
|
|
37
|
+
which is where a poisoned release would enter.
|
|
38
|
+
|
|
39
|
+
`minimumReleaseAgeExclude` exempts only `agent-sanitizer` itself, which this
|
|
40
|
+
repository builds and publishes with npm provenance. `tests/test_minimum_release_age.py`
|
|
41
|
+
fails if the window is removed or shortened, if an exemption names a version
|
|
42
|
+
nothing pins, or if a third-party package is exempted.
|
|
43
|
+
|
|
27
44
|
## What to expect
|
|
28
45
|
|
|
29
46
|
The maintainer will acknowledge your report and work with you on a fix and a
|
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.
|
|
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
|
|
232
|
-
// (see `kind` in analyzeCarve
|
|
233
|
-
//
|
|
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
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/types/invisible.d.mts
CHANGED
|
@@ -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";
|