agent-sanitizer 2.0.0
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/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
package/src/view-map.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure offset/text machinery for mapping between a file's on-disk bytes and
|
|
3
|
+
* the sanitized view the model reads (Layer 1 invisible/ANSI stripping, then
|
|
4
|
+
* Layer 4 secret redaction). No I/O — consumed by `./rehydrate.mjs`,
|
|
5
|
+
* which owns file access, the injected redactor, and policy.
|
|
6
|
+
*
|
|
7
|
+
* Coordinate spaces, disk → view:
|
|
8
|
+
* disk — the file's real bytes
|
|
9
|
+
* cleaned — disk minus the runs Layer 1 deleted (`alignDeletions` recovers
|
|
10
|
+
* them; a run at `start` sits immediately before cleaned[start])
|
|
11
|
+
* view — cleaned with each secret replaced by its [REDACTED…]
|
|
12
|
+
* placeholder (`pairs` from the injected redactor’s map mode)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Non-overlapping occurrence indices of `needle` in `haystack`.
|
|
17
|
+
* @param {string} haystack
|
|
18
|
+
* @param {string} needle
|
|
19
|
+
* @returns {number[]}
|
|
20
|
+
*/
|
|
21
|
+
export function occurrences(haystack, needle) {
|
|
22
|
+
// An empty needle has no meaningful occurrence here, and `indexOf("", k)`
|
|
23
|
+
// clamps to `haystack.length` rather than returning -1, so stepping by the
|
|
24
|
+
// needle length (0) would loop forever and grow `out` until a RangeError.
|
|
25
|
+
// Callers must never act on a zero-length match; return none.
|
|
26
|
+
if (needle === "") return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
let i = haystack.indexOf(needle);
|
|
29
|
+
while (i !== -1) {
|
|
30
|
+
out.push(i);
|
|
31
|
+
i = haystack.indexOf(needle, i + needle.length);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Count of ALL matches of `needle` in `haystack`, including self-overlapping
|
|
38
|
+
* ones (stepping by 1, not by the needle length). `occurrences` deliberately
|
|
39
|
+
* steps by the needle length so it never reports overlapping spans — correct
|
|
40
|
+
* for splicing, but it undercounts a self-overlapping needle (e.g. "aa" in
|
|
41
|
+
* "aaa" is one non-overlapping match yet two overlapping ones). Ambiguity
|
|
42
|
+
* gating must use THIS count: an old_string that overlaps itself has more than
|
|
43
|
+
* one anchor a human (or the real Edit tool) could mean, so it is ambiguous even
|
|
44
|
+
* when `occurrences` reports a single non-overlapping match.
|
|
45
|
+
* @param {string} haystack
|
|
46
|
+
* @param {string} needle
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function overlapAwareCount(haystack, needle) {
|
|
50
|
+
if (needle === "") return 0;
|
|
51
|
+
let count = 0;
|
|
52
|
+
let i = haystack.indexOf(needle);
|
|
53
|
+
while (i !== -1) {
|
|
54
|
+
count++;
|
|
55
|
+
i = haystack.indexOf(needle, i + 1);
|
|
56
|
+
}
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The character runs Layer 1 deleted, located by greedy subsequence alignment
|
|
62
|
+
* (stripping only deletes, so `cleaned` is always a subsequence of `content`).
|
|
63
|
+
* Throws if the subsequence property does not hold — the caller fails closed.
|
|
64
|
+
* @param {string} content disk bytes
|
|
65
|
+
* @param {string} cleaned Layer-1 view of the same bytes
|
|
66
|
+
* @returns {{start: number, deleted: string}[]}
|
|
67
|
+
*/
|
|
68
|
+
export function alignDeletions(content, cleaned) {
|
|
69
|
+
const deletions = [];
|
|
70
|
+
let run = "";
|
|
71
|
+
let ci = 0;
|
|
72
|
+
for (let di = 0; di < content.length; di++) {
|
|
73
|
+
if (ci < cleaned.length && content[di] === cleaned[ci]) {
|
|
74
|
+
if (run) {
|
|
75
|
+
deletions.push({ start: ci, deleted: run });
|
|
76
|
+
run = "";
|
|
77
|
+
}
|
|
78
|
+
ci++;
|
|
79
|
+
} else {
|
|
80
|
+
run += content[di];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (ci !== cleaned.length)
|
|
84
|
+
throw new Error("layer-1 view is not a subsequence of the file");
|
|
85
|
+
if (run) deletions.push({ start: ci, deleted: run });
|
|
86
|
+
return deletions;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Disk offset of cleaned-view offset `cleanedOffset`. A deleted run attaches
|
|
91
|
+
* immediately BEFORE the cleaned character at its `start`, so a span start
|
|
92
|
+
* lands after an adjacent run (preserving it) and a span end stops before one.
|
|
93
|
+
* @param {{start: number, deleted: string}[]} deletions sorted by start
|
|
94
|
+
* @param {number} cleanedOffset
|
|
95
|
+
* @param {boolean} isEnd span-end (exclusive) rather than span-start mapping
|
|
96
|
+
* @returns {number}
|
|
97
|
+
*/
|
|
98
|
+
function diskOffset(deletions, cleanedOffset, isEnd) {
|
|
99
|
+
let extra = 0;
|
|
100
|
+
for (const del of deletions) {
|
|
101
|
+
if (del.start < cleanedOffset || (!isEnd && del.start === cleanedOffset))
|
|
102
|
+
extra += del.deleted.length;
|
|
103
|
+
else break;
|
|
104
|
+
}
|
|
105
|
+
return cleanedOffset + extra;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-express each pair's `start` from a Unicode code-point offset — what the
|
|
110
|
+
* redactor's map mode emits (Python indexes strings by code point) — to a
|
|
111
|
+
* UTF-16 code-unit offset into `text`, the basis every other function here uses
|
|
112
|
+
* (JS `indexOf`/`slice`/`.length` count UTF-16 units). The two are identical for
|
|
113
|
+
* BMP-only text and diverge only when an astral character (e.g. an emoji)
|
|
114
|
+
* precedes a placeholder, where the code-point offset undercounts by one per
|
|
115
|
+
* astral char. `pair.start` is compared against UTF-16 view offsets throughout,
|
|
116
|
+
* so this conversion MUST run once at ingestion or an astral-preceded
|
|
117
|
+
* placeholder mis-anchors the edit onto the wrong bytes.
|
|
118
|
+
* @param {string} text the redacted view text the offsets index into
|
|
119
|
+
* @param {{placeholder: string, original: string, start: number}[]} pairs
|
|
120
|
+
* @returns {{placeholder: string, original: string, start: number}[]}
|
|
121
|
+
*/
|
|
122
|
+
export function pairsToUtf16(text, pairs) {
|
|
123
|
+
if (pairs.length === 0) return pairs;
|
|
124
|
+
const codePoints = Array.from(text);
|
|
125
|
+
// prefix[i] = UTF-16 length of the first i code points of `text`.
|
|
126
|
+
const prefix = new Array(codePoints.length + 1);
|
|
127
|
+
prefix[0] = 0;
|
|
128
|
+
for (let i = 0; i < codePoints.length; i++)
|
|
129
|
+
prefix[i + 1] = prefix[i] + codePoints[i].length;
|
|
130
|
+
// Code-point end of the previous pair's placeholder span. mapViewOffset's
|
|
131
|
+
// `else break` (and pairDiskSpans) assume pairs are sorted by start and never
|
|
132
|
+
// overlap; an out-of-order or overlapping pair would make the scan stop early
|
|
133
|
+
// and mis-map an offset onto the wrong bytes. Enforce the contract here.
|
|
134
|
+
let prevEnd = 0;
|
|
135
|
+
return pairs.map((pair) => {
|
|
136
|
+
// A redactor offset outside [0, codePoints.length] indexes `prefix` out of
|
|
137
|
+
// range and would silently yield `start: undefined`, which then poisons
|
|
138
|
+
// every downstream offset comparison (undefined < n is always false) and
|
|
139
|
+
// mis-anchors or corrupts the edit. Fail loudly instead — an out-of-range
|
|
140
|
+
// pair means the injected redactor's map contract was violated.
|
|
141
|
+
if (
|
|
142
|
+
!Number.isInteger(pair.start) ||
|
|
143
|
+
pair.start < 0 ||
|
|
144
|
+
pair.start > codePoints.length
|
|
145
|
+
)
|
|
146
|
+
throw new Error(
|
|
147
|
+
`redaction pair start ${pair.start} is out of range [0, ${codePoints.length}]`,
|
|
148
|
+
);
|
|
149
|
+
// Sorted + non-overlapping: `start` must be monotonically non-decreasing and
|
|
150
|
+
// each pair's placeholder span must end at or before the next pair's start.
|
|
151
|
+
// `prevEnd` already encodes the previous end, so `start < prevEnd` catches
|
|
152
|
+
// both an out-of-order start and an overlap in one comparison. Fail closed.
|
|
153
|
+
if (pair.start < prevEnd)
|
|
154
|
+
throw new Error(
|
|
155
|
+
`redaction pairs must be sorted and non-overlapping: pair start ${pair.start} precedes previous pair end ${prevEnd}`,
|
|
156
|
+
);
|
|
157
|
+
prevEnd = pair.start + Array.from(pair.placeholder).length;
|
|
158
|
+
return { ...pair, start: prefix[pair.start] };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Map a redacted-view offset to its Layer-1-cleaned offset, or null when the
|
|
164
|
+
* offset falls strictly inside a placeholder (no cleaned position corresponds).
|
|
165
|
+
* @param {{placeholder: string, original: string, start: number}[]} pairs
|
|
166
|
+
* @param {number} offset view offset
|
|
167
|
+
* @returns {number | null}
|
|
168
|
+
*/
|
|
169
|
+
function mapViewOffset(pairs, offset) {
|
|
170
|
+
let delta = 0;
|
|
171
|
+
for (const pair of pairs) {
|
|
172
|
+
const end = pair.start + pair.placeholder.length;
|
|
173
|
+
if (end <= offset) delta += pair.placeholder.length - pair.original.length;
|
|
174
|
+
else if (pair.start < offset) return null;
|
|
175
|
+
else break;
|
|
176
|
+
}
|
|
177
|
+
return offset - delta;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction
|
|
182
|
+
* pairs it wholly contains, mapping across placeholder expansion (view →
|
|
183
|
+
* cleaned) and stripped invisible runs (cleaned → disk). Null when a boundary
|
|
184
|
+
* cuts through a placeholder. `invisibleBytes` counts stripped characters
|
|
185
|
+
* inside the span (replaced along with it); runs at the boundaries stay
|
|
186
|
+
* outside and are preserved. `cleanedText` is the span's Layer-1 view — the
|
|
187
|
+
* caller MUST verify that re-cleaning `diskText` reproduces it before acting:
|
|
188
|
+
* greedy alignment is ambiguous when a deleted run's edge character equals the
|
|
189
|
+
* adjacent kept character (an ANSI sequence ending in `m` before a kept `m`),
|
|
190
|
+
* and a mis-attributed run would mis-anchor the edit.
|
|
191
|
+
* @param {string} content disk file content
|
|
192
|
+
* @param {string} cleaned Layer-1 view of `content`
|
|
193
|
+
* @param {{text: string, pairs: {placeholder: string, original: string, start: number}[]}} view
|
|
194
|
+
* @param {{start: number, deleted: string}[]} deletions
|
|
195
|
+
* @param {number} viewStart
|
|
196
|
+
* @param {number} viewEnd
|
|
197
|
+
*/
|
|
198
|
+
export function resolveSpan(
|
|
199
|
+
content,
|
|
200
|
+
cleaned,
|
|
201
|
+
view,
|
|
202
|
+
deletions,
|
|
203
|
+
viewStart,
|
|
204
|
+
viewEnd,
|
|
205
|
+
) {
|
|
206
|
+
const cleanedStart = mapViewOffset(view.pairs, viewStart);
|
|
207
|
+
const cleanedEnd = mapViewOffset(view.pairs, viewEnd);
|
|
208
|
+
if (cleanedStart === null || cleanedEnd === null) return null;
|
|
209
|
+
const diskText = content.slice(
|
|
210
|
+
diskOffset(deletions, cleanedStart, false),
|
|
211
|
+
diskOffset(deletions, cleanedEnd, true),
|
|
212
|
+
);
|
|
213
|
+
return {
|
|
214
|
+
diskText,
|
|
215
|
+
cleanedText: cleaned.slice(cleanedStart, cleanedEnd),
|
|
216
|
+
invisibleBytes: diskText.length - (cleanedEnd - cleanedStart),
|
|
217
|
+
pairs: view.pairs.filter(
|
|
218
|
+
(pair) =>
|
|
219
|
+
pair.start >= viewStart &&
|
|
220
|
+
pair.start + pair.placeholder.length <= viewEnd,
|
|
221
|
+
),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* All occurrences of any needle in `text`, ordered by position. Placeholder
|
|
227
|
+
* texts never substring-overlap one another (each ends in "]" right after its
|
|
228
|
+
* distinguishing label), so the sorted matches are non-overlapping.
|
|
229
|
+
* @param {string} text
|
|
230
|
+
* @param {string[]} needles
|
|
231
|
+
* @returns {{text: string, index: number}[]}
|
|
232
|
+
*/
|
|
233
|
+
export function orderedMatches(text, needles) {
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const needle of needles)
|
|
236
|
+
for (const index of occurrences(text, needle))
|
|
237
|
+
out.push({ text: needle, index });
|
|
238
|
+
return out.sort((left, right) => left.index - right.index);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* On-disk [start, end) span of every redaction pair, mapped from its view
|
|
243
|
+
* offset through placeholder expansion (view → cleaned) and stripped invisible
|
|
244
|
+
* runs (cleaned → disk). A run abutting the secret stays outside its span (it
|
|
245
|
+
* was never part of the secret); interior runs are included. Callers use these
|
|
246
|
+
* to detect an edit whose on-disk footprint intrudes into bytes the model was
|
|
247
|
+
* never shown.
|
|
248
|
+
* @param {{pairs: {placeholder: string, original: string, start: number}[]}} view
|
|
249
|
+
* @param {{start: number, deleted: string}[]} deletions
|
|
250
|
+
* @returns {{start: number, end: number}[]}
|
|
251
|
+
*/
|
|
252
|
+
export function pairDiskSpans(view, deletions) {
|
|
253
|
+
return view.pairs.map((pair) => {
|
|
254
|
+
// pair.start is a placeholder boundary; placeholders never overlap, so it is
|
|
255
|
+
// never strictly interior to another placeholder and mapViewOffset resolves.
|
|
256
|
+
const cleanedStart = mapViewOffset(view.pairs, pair.start);
|
|
257
|
+
if (cleanedStart === null)
|
|
258
|
+
throw new Error("redaction pair start maps inside another placeholder");
|
|
259
|
+
const cleanedEnd = cleanedStart + pair.original.length;
|
|
260
|
+
return {
|
|
261
|
+
start: diskOffset(deletions, cleanedStart, false),
|
|
262
|
+
end: diskOffset(deletions, cleanedEnd, true),
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Substitute the placeholders in a model-authored new_string with the secrets
|
|
269
|
+
* they stand for. Resolution, strictest first: if the new placeholder
|
|
270
|
+
* sequence equals the matched span's, map 1:1 by position; otherwise each
|
|
271
|
+
* placeholder text must name a single distinct secret within the span. A
|
|
272
|
+
* placeholder naming a secret outside the span, or one whose text also
|
|
273
|
+
* appears literally in the matched file text, is unresolvable → deny.
|
|
274
|
+
* @param {string} oldS matched old_string (≡ the view span text)
|
|
275
|
+
* @param {string} newS model-authored replacement
|
|
276
|
+
* @param {{placeholder: string, original: string, start: number}[]} spanPairs
|
|
277
|
+
* @param {{placeholder: string, original: string, start: number}[]} filePairs
|
|
278
|
+
* @returns {{text: string, secrets: string[]} | {deny: string}}
|
|
279
|
+
*/
|
|
280
|
+
export function rehydrateNewString(oldS, newS, spanPairs, filePairs) {
|
|
281
|
+
const spanTexts = [...new Set(spanPairs.map((pair) => pair.placeholder))];
|
|
282
|
+
for (const phText of new Set(filePairs.map((pair) => pair.placeholder))) {
|
|
283
|
+
if (!newS.includes(phText)) continue;
|
|
284
|
+
if (!spanTexts.includes(phText)) {
|
|
285
|
+
if (!oldS.includes(phText))
|
|
286
|
+
return {
|
|
287
|
+
deny:
|
|
288
|
+
`new_string contains "${phText}", which stands for a redacted secret outside ` +
|
|
289
|
+
`the matched old_string; extend old_string to cover that secret, or drop it`,
|
|
290
|
+
};
|
|
291
|
+
continue; // literal file text the model matched verbatim
|
|
292
|
+
}
|
|
293
|
+
const produced = spanPairs.filter(
|
|
294
|
+
(pair) => pair.placeholder === phText,
|
|
295
|
+
).length;
|
|
296
|
+
if (occurrences(oldS, phText).length > produced)
|
|
297
|
+
return {
|
|
298
|
+
deny:
|
|
299
|
+
`the matched text mixes literal "${phText}" text with a redacted secret sharing ` +
|
|
300
|
+
`that placeholder; cannot tell which occurrences in new_string are which — ` +
|
|
301
|
+
`edit the literal text and the secret's line separately`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// With an empty span (the verbatim fast path) both sequences below are
|
|
305
|
+
// empty, so newS falls through unchanged.
|
|
306
|
+
const newSeq = orderedMatches(newS, spanTexts);
|
|
307
|
+
if (
|
|
308
|
+
newSeq.length === spanPairs.length &&
|
|
309
|
+
newSeq.every((match, i) => match.text === spanPairs[i].placeholder)
|
|
310
|
+
) {
|
|
311
|
+
let out = "";
|
|
312
|
+
let last = 0;
|
|
313
|
+
newSeq.forEach((match, i) => {
|
|
314
|
+
out += newS.slice(last, match.index) + spanPairs[i].original;
|
|
315
|
+
last = match.index + match.text.length;
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
text: out + newS.slice(last),
|
|
319
|
+
secrets: spanPairs.map((pair) => pair.original),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Each placeholder text must name exactly one secret; resolve that mapping
|
|
324
|
+
// first, then splice in a SINGLE ordered pass. A chained
|
|
325
|
+
// `out.split(ph).join(secret)` per placeholder is unsound: an inserted secret
|
|
326
|
+
// whose bytes happen to contain a later placeholder text would be re-matched
|
|
327
|
+
// and corrupted (or partially exposed) by the next split. One pass over the
|
|
328
|
+
// ordered match positions only ever touches the original new_string bytes.
|
|
329
|
+
const valueByPh = new Map();
|
|
330
|
+
for (const phText of new Set(newSeq.map((match) => match.text))) {
|
|
331
|
+
const values = [
|
|
332
|
+
...new Set(
|
|
333
|
+
spanPairs
|
|
334
|
+
.filter((pair) => pair.placeholder === phText)
|
|
335
|
+
.map((pair) => pair.original),
|
|
336
|
+
),
|
|
337
|
+
];
|
|
338
|
+
if (values.length > 1)
|
|
339
|
+
return {
|
|
340
|
+
deny:
|
|
341
|
+
`multiple distinct secrets in the matched text share the placeholder "${phText}" ` +
|
|
342
|
+
`and new_string changes their count or order; keep each one in place, or ` +
|
|
343
|
+
`edit them one at a time with unique surrounding context`,
|
|
344
|
+
};
|
|
345
|
+
valueByPh.set(phText, values[0]);
|
|
346
|
+
}
|
|
347
|
+
let out = "";
|
|
348
|
+
let last = 0;
|
|
349
|
+
for (const match of newSeq) {
|
|
350
|
+
out += newS.slice(last, match.index) + valueByPh.get(match.text);
|
|
351
|
+
last = match.index + match.text.length;
|
|
352
|
+
}
|
|
353
|
+
return { text: out + newS.slice(last), secrets: [...valueByPh.values()] };
|
|
354
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by scripts/gen-invisible-charset.mjs from Node's Unicode data
|
|
3
|
+
* (\p{Cf}, Unicode 17.0) — DO NOT EDIT.
|
|
4
|
+
*
|
|
5
|
+
* The general-category Cf code points, PINNED at generation time. invisible.mjs
|
|
6
|
+
* strips exactly this set instead of testing \p{Cf} live, and the Python port
|
|
7
|
+
* reads the SAME set from data/invisible-charset.json's `cf_codepoints`, so both
|
|
8
|
+
* layers strip an identical Cf set regardless of each runtime's own Unicode
|
|
9
|
+
* version. Regenerate with `node scripts/gen-invisible-charset.mjs`;
|
|
10
|
+
* test/invisible-charset.test.mjs fails if this drifts from Node's \p{Cf}.
|
|
11
|
+
*/
|
|
12
|
+
export const UNICODE_VERSION: "17.0";
|
|
13
|
+
/** @type {readonly number[]} Sorted ascending. */
|
|
14
|
+
export const CF_CODEPOINTS: readonly number[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True iff any UTF-16 code unit is outside ASCII (> 0x7F). Surrogates (astral
|
|
3
|
+
* chars) are >= 0xD800 so they count; ASCII control chars (tab, newline) stay
|
|
4
|
+
* ASCII. A plain loop, not a regex, to avoid a control char in the pattern.
|
|
5
|
+
* @param {string} value
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function hasNonAscii(value: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Model-facing note naming the fields whose confusables were folded.
|
|
11
|
+
* @param {string[]} normalized
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeContext(normalized: string[]): string;
|
|
15
|
+
/**
|
|
16
|
+
* Replace every scan-flagged confusable with its ASCII (latin) equivalent.
|
|
17
|
+
* `index` is a UTF-16 offset into `text` and `char` is the matched glyph (which
|
|
18
|
+
* may be an astral, 2-unit char); splice highest-index first so a
|
|
19
|
+
* length-changing fold never shifts the offsets of earlier findings.
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @param {Array<{ index: number, char: string, latinEquivalent: string }>} findings
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function foldConfusables(text: string, findings: Array<{
|
|
25
|
+
index: number;
|
|
26
|
+
char: string;
|
|
27
|
+
latinEquivalent: string;
|
|
28
|
+
}>): string;
|
|
29
|
+
/**
|
|
30
|
+
* Normalize confusable/homoglyph chars in the path/command fields of a tool
|
|
31
|
+
* call. Returns the updated input plus the fields touched, or null when nothing
|
|
32
|
+
* changed. Throws if the injected scanner fails (the caller fails closed: an
|
|
33
|
+
* un-normalized confusable could slip past a deny rule).
|
|
34
|
+
*
|
|
35
|
+
* `scan` is the injected confusable engine: `scan(text)` → `{ findings }` (an
|
|
36
|
+
* empty `findings` means no confusables). `fields` maps a tool name to the
|
|
37
|
+
* input keys to fold; defaults to {@link DEFAULT_FIELDS}.
|
|
38
|
+
* @param {string} tool
|
|
39
|
+
* @param {any} toolInput
|
|
40
|
+
* @param {{ scan: (text: string) => { findings: Array<{ index: number, char: string, latinEquivalent: string }> }, fields?: Record<string, string[]> }} options
|
|
41
|
+
* @returns {{ updatedInput: any, normalized: string[] } | null}
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeConfusables(tool: string, toolInput: any, { scan, fields }: {
|
|
44
|
+
scan: (text: string) => {
|
|
45
|
+
findings: Array<{
|
|
46
|
+
index: number;
|
|
47
|
+
char: string;
|
|
48
|
+
latinEquivalent: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
fields?: Record<string, string[]>;
|
|
52
|
+
}): {
|
|
53
|
+
updatedInput: any;
|
|
54
|
+
normalized: string[];
|
|
55
|
+
} | null;
|
|
56
|
+
/**
|
|
57
|
+
* Confusable / homoglyph folding for tool-call INPUT fields.
|
|
58
|
+
*
|
|
59
|
+
* Folding look-alike glyphs to their ASCII canon narrows the steganographic
|
|
60
|
+
* channel a model-to-model paste can open and closes the cross-script deny-rule
|
|
61
|
+
* bypass of CVE-2025-54794: a Cyrillic "а" dressed as ASCII "a" would not match
|
|
62
|
+
* an ASCII deny rule, so an attacker could slip a denied path/command past a
|
|
63
|
+
* filter by spelling it in look-alike code points.
|
|
64
|
+
*
|
|
65
|
+
* Folding is per-character and context-free: every glyph the injected scanner
|
|
66
|
+
* flags is replaced with its ASCII (latin) equivalent regardless of its
|
|
67
|
+
* neighbours. This deliberately catches an ISOLATED confusable with no ASCII
|
|
68
|
+
* anchor (a lone Cyrillic "а" in "/а") that a context-SENSITIVE canonicaliser
|
|
69
|
+
* would leave untouched — exactly the bypass to close — while leaving genuine
|
|
70
|
+
* non-confusable non-ASCII (accented Latin, CJK, emoji) alone, since a faithful
|
|
71
|
+
* scanner does not flag those.
|
|
72
|
+
*
|
|
73
|
+
* The confusable scanner is INJECTED, never imported: the canonical engine
|
|
74
|
+
* (namespace-guard's vision-weighted map) is a heavy, separately-owned peer.
|
|
75
|
+
* Pass `{ scan }` where `scan(text)` returns `{ findings: [{ index, char,
|
|
76
|
+
* latinEquivalent }] }` — `index` a UTF-16 offset, `char` the matched glyph
|
|
77
|
+
* (possibly a 2-unit astral char), `latinEquivalent` its ASCII canon.
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* Default path/command fields to fold per tool. Agent-agnostic: the keys are
|
|
81
|
+
* the conventional Claude/Anthropic tool names, but a caller with a different
|
|
82
|
+
* tool surface passes its own `fields` map.
|
|
83
|
+
* @type {Record<string, string[]>}
|
|
84
|
+
*/
|
|
85
|
+
export const DEFAULT_FIELDS: Record<string, string[]>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when either pre-gate alternation shape-matches `text`. Split into two
|
|
3
|
+
* literals (see SECRET_HINT) and OR'd so neither grows into a
|
|
4
|
+
* polynomial-backtracking shape.
|
|
5
|
+
* @param {string} text
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function matchesSecretHint(text: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Cheap, dependency-free pre-gates shared by the HTML layer (Layers 2 & 3) and
|
|
11
|
+
* re-exported from both the package root and the `./html` subpath.
|
|
12
|
+
*
|
|
13
|
+
* These are pulled out of `html.mjs` so the package root can re-export them
|
|
14
|
+
* without dragging in the heavy remark/rehype/unified graph: a static
|
|
15
|
+
* `export … from "./html.mjs"` would eagerly evaluate that ~200ms module on
|
|
16
|
+
* every root import, defeating the lazy-load design. This module imports
|
|
17
|
+
* nothing, so re-exporting it is free.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Matches any HTML tag-like construct: opening tags, closing tags (`</`),
|
|
21
|
+
* comments and bogus declarations (`<!`), and processing instructions / bogus
|
|
22
|
+
* comments (`<?…?>`, which the HTML tokenizer hides exactly like a comment).
|
|
23
|
+
* The `<?` arm is what lets a PI-only document reach Layer 2's bogus-comment
|
|
24
|
+
* splice; without it such a document would skip the pipeline entirely. Gate for
|
|
25
|
+
* Layer 2 (HTML sanitization) and the HTML img/a exfil path in Layer 3.
|
|
26
|
+
*/
|
|
27
|
+
export const HTML_TAG_PRESENT: RegExp;
|
|
28
|
+
/**
|
|
29
|
+
* Matches markdown link/image syntax (`](`, `![`) and reference link
|
|
30
|
+
* definitions (`[label]: url` at line start). Gate for Layer 3 (markdown
|
|
31
|
+
* exfiltration detection).
|
|
32
|
+
*/
|
|
33
|
+
export const MD_LINK_HINT: RegExp;
|
|
34
|
+
/** @type {RegExp} */
|
|
35
|
+
export const SECRET_HINT: RegExp;
|
|
36
|
+
/** @type {RegExp} */
|
|
37
|
+
export const SECRET_HINT_EXT: RegExp;
|
package/types/html.d.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} styleStr
|
|
3
|
+
* @returns {boolean}
|
|
4
|
+
*/
|
|
5
|
+
export function isHiddenStyle(styleStr: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* True for an element a rendered page would not show: `hidden` attribute or a
|
|
8
|
+
* hiding inline style. Works on both hast nodes and parseHtmlTag results.
|
|
9
|
+
* @param {any} node
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isHiddenElement(node: any): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} htmlValue
|
|
15
|
+
* @returns {string | null}
|
|
16
|
+
*/
|
|
17
|
+
export function isHiddenOpen(htmlValue: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} htmlValue
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function closingTagName(htmlValue: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Replace each range of `text` with its kind's placeholder, preserving every
|
|
25
|
+
* byte outside the ranges verbatim. Overlapping/nested ranges are merged
|
|
26
|
+
* (defense-in-depth — the scanners emit disjoint ranges).
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function spliceRanges(text: string, ranges: Array<{
|
|
32
|
+
start: number;
|
|
33
|
+
end: number;
|
|
34
|
+
kind: "comment" | "hidden";
|
|
35
|
+
}>): string;
|
|
36
|
+
/**
|
|
37
|
+
* Scan raw HTML for hidden content to strip and preserved tags to report.
|
|
38
|
+
* Returned ranges are offsets into `html`; comments and hidden elements span
|
|
39
|
+
* the whole element including its content (rehype positions cover open tag
|
|
40
|
+
* through matching close, and parse5 extends an unclosed element to the end
|
|
41
|
+
* of the fragment — fail-closed for truncated markup).
|
|
42
|
+
* @param {string} html
|
|
43
|
+
* @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
|
|
44
|
+
*/
|
|
45
|
+
export function scanHtmlFragment(html: string): {
|
|
46
|
+
ranges: Array<{
|
|
47
|
+
start: number;
|
|
48
|
+
end: number;
|
|
49
|
+
kind: "comment" | "hidden";
|
|
50
|
+
}>;
|
|
51
|
+
warned: ReturnType<typeof newWarned>;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} text
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function looksLikeHtmlSource(text: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Layer 2 over web-ingress text: splice out HTML comments and hidden elements
|
|
60
|
+
* (placeholders mark the cuts; all other bytes are preserved verbatim) and
|
|
61
|
+
* count preserved scripting/resource tags for the caller's warning. Returns
|
|
62
|
+
* null when there is nothing to strip and nothing to report.
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number } } | null}
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeHtml(text: string): {
|
|
67
|
+
text: string;
|
|
68
|
+
removed: {
|
|
69
|
+
comments: number;
|
|
70
|
+
hidden: number;
|
|
71
|
+
};
|
|
72
|
+
warned: {
|
|
73
|
+
tags: Record<string, number>;
|
|
74
|
+
dataSrc: number;
|
|
75
|
+
};
|
|
76
|
+
} | null;
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} url
|
|
79
|
+
* @returns {string | null}
|
|
80
|
+
*/
|
|
81
|
+
export function checkExfilUrl(url: string): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Host of a flagged URL — enough for the warning to name the destination
|
|
84
|
+
* without echoing the payload-bearing query/fragment.
|
|
85
|
+
* @param {string} url
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function urlHost(url: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* Layer 3: report data-exfil-shaped URLs in markdown links/images/definitions
|
|
91
|
+
* and HTML attributes (src/href/background/srcset/ping, form action/formaction,
|
|
92
|
+
* meta-refresh). Detection only — the text is never modified; the caller
|
|
93
|
+
* surfaces the threats as a warning.
|
|
94
|
+
* @param {string} text
|
|
95
|
+
* @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
|
|
96
|
+
*/
|
|
97
|
+
export function detectExfil(text: string): Array<{
|
|
98
|
+
isImage: boolean;
|
|
99
|
+
reason: string;
|
|
100
|
+
target: string;
|
|
101
|
+
}> | null;
|
|
102
|
+
export const REPORTED_TAGS: Set<string>;
|
|
103
|
+
export const COMMENT_PLACEHOLDER: "[HTML comment removed]";
|
|
104
|
+
export const HIDDEN_PLACEHOLDER: "[hidden HTML removed]";
|
|
105
|
+
export const UNPARSEABLE_PLACEHOLDER: "[HTML unparseable \u2014 withheld]";
|
|
106
|
+
export const DATA_URI_LENGTH_THRESHOLD: 4096;
|
|
107
|
+
/** @returns {{ tags: Record<string, number>, dataSrc: number }} */
|
|
108
|
+
declare function newWarned(): {
|
|
109
|
+
tags: Record<string, number>;
|
|
110
|
+
dataSrc: number;
|
|
111
|
+
};
|
|
112
|
+
import { HTML_TAG_PRESENT } from "./gates.mjs";
|
|
113
|
+
import { MD_LINK_HINT } from "./gates.mjs";
|
|
114
|
+
import { SECRET_HINT } from "./gates.mjs";
|
|
115
|
+
import { SECRET_HINT_EXT } from "./gates.mjs";
|
|
116
|
+
import { matchesSecretHint } from "./gates.mjs";
|
|
117
|
+
export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize untrusted text before any LLM sees it.
|
|
3
|
+
*
|
|
4
|
+
* Always runs Layer 1 (invisible-char + ANSI stripping, lone-surrogate
|
|
5
|
+
* normalization). When `html` is true, also lazy-loads the HTML layer to splice
|
|
6
|
+
* out human-invisible HTML (comments, hidden elements — Layer 2) and detect
|
|
7
|
+
* data-exfil-shaped URLs (Layer 3); the heavy remark/rehype dependency is only
|
|
8
|
+
* imported on that path. The exfil scan runs on the pre-splice text so a beacon
|
|
9
|
+
* URL hidden inside a `display:none` element is still reported, not buried by
|
|
10
|
+
* its own removal.
|
|
11
|
+
*
|
|
12
|
+
* `found` names the categories neutralized; `warnings` carries the
|
|
13
|
+
* operator-facing notices. `cleaned` is always a string, and a change only
|
|
14
|
+
* ever carries a warning (no silent suppression). `options` is optional and
|
|
15
|
+
* tolerates an explicit `null`/`undefined` (treated the same as omitted) —
|
|
16
|
+
* only a genuinely malformed `text` (not a string) throws, deliberately: a
|
|
17
|
+
* caller passing the wrong TYPE for `text` gets a clear, named error instead
|
|
18
|
+
* of an internal TypeError leaking implementation details (or a silent, wrong
|
|
19
|
+
* coercion of e.g. a number to a string).
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @param {{ html?: boolean } | null} [options]
|
|
22
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
23
|
+
*/
|
|
24
|
+
export function sanitize(text: string, options?: {
|
|
25
|
+
html?: boolean;
|
|
26
|
+
} | null): Promise<{
|
|
27
|
+
cleaned: string;
|
|
28
|
+
found: string[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}>;
|
|
31
|
+
export { applyLayer1, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
|
|
32
|
+
export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
|
|
33
|
+
export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint } from "./gates.mjs";
|