agent-sanitizer 2.47.1 → 2.47.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 +15 -2
- package/package.json +1 -1
- package/src/invisible.mjs +16 -5
package/THREAT-MODEL.md
CHANGED
|
@@ -669,8 +669,21 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
669
669
|
code in the session, which is why the provisioner creates that directory mode
|
|
670
670
|
700 and installs the binary mode 700. A user-owned data directory is the
|
|
671
671
|
assumption; a shared or world-writable one is not supported.
|
|
672
|
-
- **
|
|
673
|
-
|
|
672
|
+
- **A second artifact under that directory is loaded as code: V8's compile
|
|
673
|
+
cache.** The launcher points node at
|
|
674
|
+
`${CLAUDE_PLUGIN_DATA}/node-compile-cache` so the hook bundle is compiled once
|
|
675
|
+
per install instead of once per tool call. V8 does not treat a code cache as
|
|
676
|
+
untrusted input, so those files are code the session runs, exactly like the
|
|
677
|
+
binary above. The launcher creates the directory mode 700 and gates it with
|
|
678
|
+
`trusted_exec_dir` — the same owner-only-write property, refused with a line on
|
|
679
|
+
stderr when the directory does not hold it — so the same user-owned data
|
|
680
|
+
directory is the assumption here too. It carries no digest and needs none:
|
|
681
|
+
anything able to write there can already run code per the bullet above. Nothing
|
|
682
|
+
is fetched for it, so it adds no supply-chain surface.
|
|
683
|
+
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
684
|
+
a binary. That leaves the node path running the committed bundle and the
|
|
685
|
+
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
686
|
+
well, and an operator's own `NODE_COMPILE_CACHE` is used unchanged.
|
|
674
687
|
|
|
675
688
|
## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
|
|
676
689
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.47.
|
|
3
|
+
"version": "2.47.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
|
@@ -1084,9 +1084,20 @@ function isPreservedBlankFiller(cps, i) {
|
|
|
1084
1084
|
// defines the unit the preserve budget is charged against (see carveStrip).
|
|
1085
1085
|
// Grapheme segmentation is locale-independent, so the locale is pinned to "en"
|
|
1086
1086
|
// only for determinism across hosts.
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1087
|
+
//
|
|
1088
|
+
// Built on FIRST USE, never at import: constructing it loads ICU's segmentation
|
|
1089
|
+
// tables and costs ~10 ms, the largest single item in this module's setup, and
|
|
1090
|
+
// only text with carve candidates ever reaches clusterResolver. The hooks that
|
|
1091
|
+
// import this run on every tool call, so building it at import charged every
|
|
1092
|
+
// call for a segmenter almost none of them use.
|
|
1093
|
+
/** @type {Intl.Segmenter | null} */
|
|
1094
|
+
let segmenterCache = null;
|
|
1095
|
+
|
|
1096
|
+
/** @returns {Intl.Segmenter} the process-wide grapheme segmenter. */
|
|
1097
|
+
function graphemeSegmenter() {
|
|
1098
|
+
segmenterCache ??= new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
1099
|
+
return segmenterCache;
|
|
1100
|
+
}
|
|
1090
1101
|
|
|
1091
1102
|
/**
|
|
1092
1103
|
* The UTF-16 index at which each code point of `cps` starts, plus one final
|
|
@@ -1148,7 +1159,7 @@ const CONTAINING_CANDIDATE_SHARE = 4;
|
|
|
1148
1159
|
function clusterResolver(body, offsets, candidates) {
|
|
1149
1160
|
const cpCount = offsets.length - 1;
|
|
1150
1161
|
if (candidates * CONTAINING_CANDIDATE_SHARE < cpCount) {
|
|
1151
|
-
const segments =
|
|
1162
|
+
const segments = graphemeSegmenter().segment(body);
|
|
1152
1163
|
return (cp) => {
|
|
1153
1164
|
// `offsets[cp]` is inside the string for every code-point index the walk
|
|
1154
1165
|
// asks about, so the segment always exists.
|
|
@@ -1165,7 +1176,7 @@ function clusterResolver(body, offsets, candidates) {
|
|
|
1165
1176
|
// cursor in step with it so no boundary needs searching for. The iterator is
|
|
1166
1177
|
// abandoned wherever the last candidate leaves it, so a document whose
|
|
1167
1178
|
// preserve budget is spent in its first line is never segmented past it.
|
|
1168
|
-
const iterator =
|
|
1179
|
+
const iterator = graphemeSegmenter().segment(body)[Symbol.iterator]();
|
|
1169
1180
|
let start = 0;
|
|
1170
1181
|
let end = 0;
|
|
1171
1182
|
return (cp) => {
|