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.
@@ -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;
@@ -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";