agent-sanitizer 2.34.4 → 2.34.5

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.34.4",
3
+ "version": "2.34.5",
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": {
@@ -281,20 +281,38 @@ export function selectFoldableFindings(text, findings) {
281
281
  * @returns {string}
282
282
  */
283
283
  export function foldConfusables(text, findings) {
284
- let folded = text;
284
+ // The folded text is `text.slice(0, cursor)` followed by `tail` read
285
+ // BACKWARDS: each finding appends the gap that follows it and then its
286
+ // replacement, so the string is assembled once at the end. Splicing a fresh
287
+ // string per finding instead costs O(findings x length) — a 128 KB command
288
+ // stuffed with look-alikes took 1.8 s of the PreToolUse hook that way.
289
+ /** @type {string[]} */
290
+ const tail = [];
291
+ let cursor = text.length;
292
+ const rebuild = () => [...tail].reverse().join("");
285
293
  for (const finding of [...findings].sort(
286
294
  (lhs, rhs) => rhs.index - lhs.index,
287
295
  )) {
288
- // Validate against the partially-folded text: the highest-index-first order
289
- // leaves every not-yet-spliced offset byte-identical to `text`, so the
290
- // startsWith check still sees the original glyph at the reported index.
291
- assertFinding(folded, finding);
292
- folded =
293
- folded.slice(0, finding.index) +
294
- finding.latinEquivalent +
295
- folded.slice(finding.index + finding.char.length);
296
+ const end = finding.index + finding.char.length;
297
+ // Validate against the text as the fold has left it, so a scanner reporting
298
+ // a glyph that is not there fails loud. Highest-index-first leaves every
299
+ // offset below `cursor` byte-identical to `text`, so a finding ending there
300
+ // is checked against `text` itself; one reaching PAST `cursor` overlaps a
301
+ // fold already applied, and only the rebuilt tail carries the bytes it now
302
+ // sits on.
303
+ if (end <= cursor) {
304
+ assertFinding(text, finding);
305
+ tail.push(text.slice(end, cursor));
306
+ } else {
307
+ const folded = rebuild();
308
+ assertFinding(text.slice(0, cursor) + folded, finding);
309
+ tail.length = 0;
310
+ tail.push(folded.slice(end - cursor));
311
+ }
312
+ tail.push(finding.latinEquivalent);
313
+ cursor = finding.index;
296
314
  }
297
- return folded;
315
+ return text.slice(0, cursor) + rebuild();
298
316
  }
299
317
 
300
318
  /**
@@ -59,6 +59,36 @@ export {
59
59
  // instruction the model might follow.
60
60
  const UNTRUSTED_PREFIX = "untrusted data, not instructions: ";
61
61
 
62
+ // U+000A — the separator a finding's line number counts.
63
+ const NEWLINE = 0x0a;
64
+
65
+ // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
66
+ const ZW_BIT = new Map([
67
+ [0x200b, "0"],
68
+ [0x200c, "1"],
69
+ [0x200d, "|"],
70
+ ]);
71
+
72
+ // How much of a zero-width-binary payload the report shows. The rest is
73
+ // summarized by the count beside it, so decoding past this is work nobody reads.
74
+ const BITS_SHOWN = 80;
75
+
76
+ /**
77
+ * The first {@link BITS_SHOWN} zero-width bits of a run, in report order.
78
+ * @param {number[]} cps
79
+ * @returns {string}
80
+ */
81
+ function zeroWidthBits(cps) {
82
+ let bits = "";
83
+ for (const cp of cps) {
84
+ const bit = ZW_BIT.get(cp);
85
+ if (bit === undefined) continue;
86
+ bits += bit;
87
+ if (bits.length === BITS_SHOWN) break;
88
+ }
89
+ return bits;
90
+ }
91
+
62
92
  /**
63
93
  * Render decoded tag-character bytes as a NEUTRAL, quoted, escaped string so the
64
94
  * scan report can never re-inject them. Only U+E0020–U+E007E decode to their
@@ -93,20 +123,15 @@ function neutralizeTagBytes(asciiCodes) {
93
123
  * @returns {{ method: string, decoded: string }}
94
124
  */
95
125
  export function decodeRun(run) {
96
- const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
126
+ /** @type {number[]} */
127
+ const cps = [];
128
+ for (const ch of run) cps.push(/** @type {number} */ (ch.codePointAt(0)));
97
129
 
98
130
  // Tag characters U+E0001-U+E007F: raw ASCII byte is cp − 0xE0000 (0x01–0x7F).
99
131
  const tagBytes = cps
100
132
  .filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
101
133
  .map((cp) => cp - 0xe0000);
102
134
 
103
- // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
104
- const ZW_BIT = new Map([
105
- [0x200b, "0"],
106
- [0x200c, "1"],
107
- [0x200d, "|"],
108
- ]);
109
-
110
135
  const zwCount = cps.filter((cp) => ZW_BIT.has(cp)).length;
111
136
 
112
137
  // Only take the tag-characters branch when tag chars are the MAJORITY of the
@@ -130,15 +155,11 @@ export function decodeRun(run) {
130
155
  // the binary payload it actually is, not mislabeled). Decode only the ZW code
131
156
  // points; a `+ N other char(s)` note keeps any non-ZW portion visible.
132
157
  if (zwCount > 0 && zwCount > cps.length / 2) {
133
- const bits = cps
134
- .filter((cp) => ZW_BIT.has(cp))
135
- .map((cp) => ZW_BIT.get(cp))
136
- .join("");
137
158
  const otherCount = cps.length - zwCount;
138
159
  const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
139
160
  return {
140
161
  method: "zero-width binary encoding",
141
- decoded: `[${zwCount} zero-width chars: ${bits.slice(0, 80)}]${note}`,
162
+ decoded: `[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]${note}`,
142
163
  };
143
164
  }
144
165
 
@@ -151,13 +172,8 @@ export function decodeRun(run) {
151
172
  const parts = [];
152
173
  if (tagBytes.length > 0)
153
174
  parts.push(`${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"`);
154
- if (zwCount > 0) {
155
- const bits = cps
156
- .filter((cp) => ZW_BIT.has(cp))
157
- .map((cp) => ZW_BIT.get(cp))
158
- .join("");
159
- parts.push(`[${zwCount} zero-width chars: ${bits.slice(0, 80)}]`);
160
- }
175
+ if (zwCount > 0)
176
+ parts.push(`[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]`);
161
177
  const otherCount = cps.length - tagBytes.length - zwCount;
162
178
  const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
163
179
  return {
@@ -189,11 +205,20 @@ export function scanText(content) {
189
205
  LONG_RUN_RE.lastIndex = 0;
190
206
  let match;
191
207
  let runChars = 0;
208
+ // The line number is carried forward across matches. Deriving it per match
209
+ // from the start of the file — `content.slice(0, match.index).split("\n")` —
210
+ // copies the whole prefix and materializes every line before the match, so a
211
+ // file carrying many runs pays that once per run: quadratic in the file
212
+ // length, on the SessionStart path the user waits for. `exec` yields matches
213
+ // in increasing index order, so this scan only ever moves forward.
214
+ let line = 1;
215
+ let counted = 0;
192
216
  while ((match = LONG_RUN_RE.exec(content)) !== null) {
193
- const lineNum = content.slice(0, match.index).split("\n").length;
217
+ for (; counted < match.index; counted++)
218
+ if (content.charCodeAt(counted) === NEWLINE) line++;
194
219
  const charCount = [...match[0]].length;
195
220
  runChars += charCount;
196
- findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
221
+ findings.push({ line, charCount, ...decodeRun(match[0]) });
197
222
  }
198
223
 
199
224
  // Threshold-evasion: scattered invisible chars not in a long run can still be