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 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
- - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never
673
- runs a binary, leaving the node path exactly as it was.
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.1",
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
- const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", {
1088
- granularity: "grapheme",
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 = GRAPHEME_SEGMENTER.segment(body);
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 = GRAPHEME_SEGMENTER.segment(body)[Symbol.iterator]();
1179
+ const iterator = graphemeSegmenter().segment(body)[Symbol.iterator]();
1169
1180
  let start = 0;
1170
1181
  let end = 0;
1171
1182
  return (cp) => {