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 +1 -1
- package/src/confusables.mjs +28 -10
- package/src/instructions.mjs +47 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.34.
|
|
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": {
|
package/src/confusables.mjs
CHANGED
|
@@ -281,20 +281,38 @@ export function selectFoldableFindings(text, findings) {
|
|
|
281
281
|
* @returns {string}
|
|
282
282
|
*/
|
|
283
283
|
export function foldConfusables(text, findings) {
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
315
|
+
return text.slice(0, cursor) + rebuild();
|
|
298
316
|
}
|
|
299
317
|
|
|
300
318
|
/**
|
package/src/instructions.mjs
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|