agent-input-sanitizer 1.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/src/index.mjs ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Top-level convenience entry for agent-input-sanitizer.
3
+ *
4
+ * `sanitize` always runs the zero-dependency Layer 1 (invisible-char + ANSI
5
+ * stripping, lone-surrogate normalization) and, when `html` is requested,
6
+ * lazy-loads the heavier HTML layer (Layers 2 & 3) so the remark/rehype graph
7
+ * is only paid for by callers that ask for it.
8
+ *
9
+ * The low-level building blocks stay public via the `./invisible` and `./html`
10
+ * subpath entries; import those directly when you want a single layer without
11
+ * the convenience wrapper.
12
+ */
13
+ import { stripInvisibleWithReport, LONG_RUN_RE } from "./invisible.mjs";
14
+
15
+ export {
16
+ stripInvisible,
17
+ stripInvisibleWithReport,
18
+ isSgrOnly,
19
+ STRIP,
20
+ SGR_RE,
21
+ CHECKS,
22
+ VS,
23
+ BLANK_NON_CF,
24
+ LONG_RUN_RE,
25
+ LONG_RUN_THRESHOLD,
26
+ SCATTERED_THRESHOLD,
27
+ } from "./invisible.mjs";
28
+
29
+ // Layer 2/3 cheap pre-gates. Re-exported from the dependency-free `./gates.mjs`
30
+ // (not `./html.mjs`) so consumers can share the exact HTML-tag/markdown-link
31
+ // hints and secret-shape pre-gate without duplicating the regexes — and without
32
+ // pulling in the heavy remark/rehype graph that a re-export from `./html.mjs`
33
+ // would eagerly load on every root import.
34
+ export {
35
+ HTML_TAG_PRESENT,
36
+ MD_LINK_HINT,
37
+ SECRET_HINT,
38
+ SECRET_HINT_EXT,
39
+ matchesSecretHint,
40
+ } from "./gates.mjs";
41
+
42
+ // The two raw control introducers an ANSI sequence can start with: 7-bit
43
+ // ESC (U+001B) and the 8-bit C1 CSI (U+009B). Both are category Cc, so the
44
+ // invisible-char pass (which targets Cf / variation / blank fillers) never
45
+ // removes them; the residual sweep below is what guarantees neither survives.
46
+ // eslint-disable-next-line no-control-regex -- matching the raw introducers is the point
47
+ const CONTROL_INTRODUCER_RE = /[\u001b\u009b]/g;
48
+
49
+ // Full ANSI escape grammar (CSI/SGR/OSC-with-BEL), not just SGR: the Layer-1
50
+ // guarantee is that no control introducer survives, and a cursor-move or
51
+ // erase sequence is as much a display-spoofing hazard as a color one. The
52
+ // pattern is linear (every quantified run is bounded or non-overlapping), so it
53
+ // carries no catastrophic-backtracking risk on adversarial input.
54
+ // prettier-ignore
55
+ // eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
56
+ const ANSI_RE = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
57
+
58
+ // Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
59
+ // high). Normalized before the HTML parser, which throws on a stray byte —
60
+ // which would otherwise let a single malformed code unit suppress all output.
61
+ const LONE_SURROGATE_RE =
62
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
63
+
64
+ /**
65
+ * Strip ANSI escape sequences to a fixed point. Removing one sequence can
66
+ * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
67
+ * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
68
+ * sequence the single pass would miss), so iterate until stable: every changed
69
+ * pass consumes at least one ESC introducer, so the pass count is bounded by
70
+ * the input's ESC count, and ANSI-free text exits after one pass.
71
+ * @param {string} input
72
+ * @returns {string}
73
+ */
74
+ function stripAnsiFully(input) {
75
+ let prev = input;
76
+ let out = prev.replace(ANSI_RE, "");
77
+ while (out !== prev) {
78
+ prev = out;
79
+ out = prev.replace(ANSI_RE, "");
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
86
+ * raw ANSI control introducer (7-bit ESC U+001B and 8-bit C1 CSI U+009B).
87
+ *
88
+ * Removing an invisible character can reconstitute an escape its split hid from
89
+ * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
90
+ * invisible pass — but only when stripInvisible changed something, since
91
+ * reconstitution is impossible otherwise and the re-strip is a wasted pass on
92
+ * the hot clean path. The ANSI strip still cannot match an *incomplete*
93
+ * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
94
+ * removed from a nested split), so a final sweep removes every residual raw
95
+ * introducer outright — that sweep, not the regex matching, is the guarantee
96
+ * that no control introducer survives. `deAnsi` is the ANSI strip of the
97
+ * original (invisible runs intact), the scope a LONG_RUN payload check needs.
98
+ * @param {string} text
99
+ * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
100
+ */
101
+ function applyLayer1(text) {
102
+ const deAnsi = stripAnsiFully(text);
103
+ // stripInvisibleWithReport returns `found` for exactly the categories it
104
+ // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a strip,
105
+ // and the leading-BOM exception is already handled inside it.
106
+ const { cleaned: afterInvis, found } = stripInvisibleWithReport(deAnsi);
107
+ let ansiFound = deAnsi.length !== text.length;
108
+
109
+ let cleaned = afterInvis;
110
+ if (afterInvis !== deAnsi) {
111
+ const reStripped = stripAnsiFully(afterInvis);
112
+ if (reStripped.length !== afterInvis.length) ansiFound = true;
113
+ cleaned = reStripped;
114
+ }
115
+ const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
116
+ if (swept !== cleaned) {
117
+ cleaned = swept;
118
+ ansiFound = true;
119
+ }
120
+
121
+ if (ansiFound) found.push("ANSI escapes");
122
+ return { cleaned, deAnsi, found };
123
+ }
124
+
125
+ /** @param {{ comments: number, hidden: number }} removed */
126
+ function describeRemoved(removed) {
127
+ const parts = [];
128
+ if (removed.comments > 0) parts.push(`${removed.comments} HTML comment(s)`);
129
+ if (removed.hidden > 0) parts.push(`${removed.hidden} hidden element(s)`);
130
+ return parts.join(", ");
131
+ }
132
+
133
+ /** @param {{ tags: Record<string, number>, dataSrc: number }} warned */
134
+ function describeWarned(warned) {
135
+ const parts = Object.entries(warned.tags).map(
136
+ ([tag, count]) => `${tag}×${count}`,
137
+ );
138
+ if (warned.dataSrc > 0) parts.push(`data: URI×${warned.dataSrc}`);
139
+ return parts.length > 0
140
+ ? `Preserved but reported (page source kept inspectable): ${parts.join(", ")}`
141
+ : "";
142
+ }
143
+
144
+ /**
145
+ * Sanitize untrusted text before any LLM sees it.
146
+ *
147
+ * Always runs Layer 1 (invisible-char + ANSI stripping, lone-surrogate
148
+ * normalization). When `html` is true, also lazy-loads the HTML layer to splice
149
+ * out human-invisible HTML (comments, hidden elements — Layer 2) and detect
150
+ * data-exfil-shaped URLs (Layer 3); the heavy remark/rehype dependency is only
151
+ * imported on that path. The exfil scan runs on the pre-splice text so a beacon
152
+ * URL hidden inside a `display:none` element is still reported, not buried by
153
+ * its own removal.
154
+ *
155
+ * `found` names the categories neutralized; `warnings` carries the
156
+ * operator-facing notices. `cleaned` is always a string, never throws, and
157
+ * changes only carry a warning (no silent suppression).
158
+ * @param {string} text
159
+ * @param {{ html?: boolean }} [options]
160
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
161
+ */
162
+ export async function sanitize(text, { html = false } = {}) {
163
+ /** @type {string[]} */ const found = [];
164
+ /** @type {string[]} */ const warnings = [];
165
+
166
+ const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
167
+ let cleaned = layer1;
168
+ if (invisFound.length > 0) {
169
+ found.push(...invisFound);
170
+ let msg = `Stripped: ${invisFound.join(", ")}`;
171
+ LONG_RUN_RE.lastIndex = 0;
172
+ if (LONG_RUN_RE.test(deAnsi))
173
+ msg += " [LONG RUN — possible injection payload]";
174
+ warnings.push(msg);
175
+ }
176
+
177
+ const wellFormed = cleaned.replace(LONE_SURROGATE_RE, "\uFFFD");
178
+ if (wellFormed !== cleaned) {
179
+ cleaned = wellFormed;
180
+ found.push("Lone UTF-16 surrogates");
181
+ warnings.push("Normalized lone UTF-16 surrogates");
182
+ }
183
+
184
+ if (!html) return { cleaned, found, warnings };
185
+
186
+ const { sanitizeHtml, detectExfil } = await import("./html.mjs");
187
+ // Scan for exfil URLs on the text BEFORE Layer 2 splices anything out — a
188
+ // beacon URL hidden in a comment or hidden element is more suspicious, not
189
+ // less, yet Layer 2 would otherwise remove it from view before the scan.
190
+ const preSplice = cleaned;
191
+
192
+ const layer2 = sanitizeHtml(cleaned);
193
+ if (layer2) {
194
+ if (layer2.text !== cleaned) {
195
+ cleaned = layer2.text;
196
+ if (layer2.removed.comments > 0) found.push("HTML comments");
197
+ if (layer2.removed.hidden > 0) found.push("hidden HTML");
198
+ warnings.push(
199
+ `HTML sanitized: ${describeRemoved(layer2.removed)} replaced with placeholders`,
200
+ );
201
+ }
202
+ const preserved = describeWarned(layer2.warned);
203
+ if (preserved) warnings.push(preserved);
204
+ }
205
+
206
+ const threats = detectExfil(preSplice);
207
+ if (threats) {
208
+ found.push("exfil URLs");
209
+ const reasons = [
210
+ ...new Set(
211
+ threats.map(
212
+ (threat) =>
213
+ `${threat.isImage ? "image" : "link"} to ${threat.target}: ${threat.reason}`,
214
+ ),
215
+ ),
216
+ ];
217
+ warnings.push(`Exfil-shaped URLs detected: ${reasons.join("; ")}`);
218
+ }
219
+
220
+ return { cleaned, found, warnings };
221
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Pure, zero-dependency invisible-character + ANSI/SGR primitives.
3
+ *
4
+ * Removes payload-capable Unicode (general-category Cf format chars, variation
5
+ * selectors, blank-rendering fillers, soft hyphens, interior BOMs) while
6
+ * preserving ZWNJ/ZWJ in genuine linguistic and emoji contexts, and reports
7
+ * which categories were removed.
8
+ */
9
+
10
+ export const VS = [
11
+ ...Array.from({ length: 16 }, (_, i) => 0xfe00 + i),
12
+ ...Array.from({ length: 240 }, (_, i) => 0xe0100 + i),
13
+ ]
14
+ .map((codePoint) => String.fromCodePoint(codePoint))
15
+ .join("");
16
+
17
+ // Code points that render blank / zero-width but are NOT general category Cf,
18
+ // so the \p{Cf} check below misses them: the Hangul fillers (category Lo,
19
+ // U+115F/U+1160/U+3164/U+FFA0) and the Braille blank pattern (category So,
20
+ // U+2800). A run of these carries a hidden payload exactly as zero-widths do.
21
+ export const BLANK_NON_CF = "\u115F\u1160\u3164\uFFA0\u2800";
22
+
23
+ const REGEX_FLAGS = "gu";
24
+
25
+ /** @type {Array<[string, RegExp]>} */
26
+ export const CHECKS = [
27
+ ["Format chars (Cf)", new RegExp(`\\p{Cf}`, REGEX_FLAGS)],
28
+ ["Variation selectors", new RegExp(`[${VS}]`, REGEX_FLAGS)],
29
+ ["Blank-rendering fillers", new RegExp(`[${BLANK_NON_CF}]`, REGEX_FLAGS)],
30
+ ];
31
+
32
+ export const STRIP = new RegExp(
33
+ CHECKS.map(([, regex]) => regex.source).join("|"),
34
+ REGEX_FLAGS,
35
+ );
36
+
37
+ // SGR (Select Graphic Rendition): ESC [ <digits/semicolons> m — colors, bold,
38
+ // reset. The grammar is closed: params are [0-9;]* and the final byte is `m`,
39
+ // so a match can only restyle text, never reposition the cursor, erase, or
40
+ // smuggle an OSC string. Text is "SGR-only" when removing these leaves no ESC
41
+ // byte at all — a lone or partial escape therefore is not SGR-only.
42
+ // eslint-disable-next-line no-control-regex -- matching ESC-led sequences is the point
43
+ export const SGR_RE = /\x1b\[[0-9;]*m/g;
44
+
45
+ // eslint-disable-next-line no-control-regex -- ESC (U+001B) is exactly what we test for
46
+ const ESC_RE = /\x1b/;
47
+
48
+ /**
49
+ * True when every ESC byte in `text` belongs to a display-only SGR color
50
+ * sequence (so stripping the ANSI removed only cosmetic styling, nothing that
51
+ * could move the cursor, erase, or carry a payload).
52
+ * @param {string} text
53
+ * @returns {boolean}
54
+ */
55
+ export function isSgrOnly(text) {
56
+ return !ESC_RE.test(text.replace(SGR_RE, ""));
57
+ }
58
+
59
+ export const LONG_RUN_THRESHOLD = 10;
60
+
61
+ /** Total invisible-char count above which a file/prompt is treated as
62
+ * payload-capable even without a long run (threshold-evasion catch). */
63
+ export const SCATTERED_THRESHOLD = 30;
64
+
65
+ export const LONG_RUN_RE = new RegExp(
66
+ `(?:${STRIP.source}){${LONG_RUN_THRESHOLD},}`,
67
+ REGEX_FLAGS,
68
+ );
69
+
70
+ // Leading-BOM marker, preserved by stripInvisibleWithReport (see its doc).
71
+ const BOM = "\uFEFF";
72
+ // ─── ZWNJ/ZWJ linguistic carve-out ───────────────────────────────────────────
73
+ // ZWNJ (U+200C) and ZWJ (U+200D) are general category Cf, so the STRIP pass
74
+ // would treat them as hidden-payload bytes. But they are MANDATORY for correct
75
+ // rendering between letters of several scripts (Arabic/Persian and many Indic
76
+ // scripts) and inside emoji ZWJ sequences — blanket stripping corrupts
77
+ // legitimate non-English output. Preserve them ONLY in an unambiguous
78
+ // linguistic context (immediately between two letters of such a script, or
79
+ // between two members of an emoji ZWJ sequence) and strip them as a payload
80
+ // everywhere else: a long run, scattered past SCATTERED_THRESHOLD, a
81
+ // leading/trailing position, or between Latin/ASCII/secret-shaped characters.
82
+ // Over-strip beats under-strip — the carve-out fires only when BOTH neighbors
83
+ // clearly belong to the context.
84
+ const ZWNJ = 0x200c;
85
+ const ZWJ = 0x200d;
86
+
87
+ // Scripts whose orthography uses ZWNJ/ZWJ between letters as a rendering
88
+ // control. Single source of truth: LINGUISTIC_LETTER is built from this list,
89
+ // and the test suite drives one preserve-case per entry, so adding a script
90
+ // here without a matching test fails.
91
+ export const LINGUISTIC_SCRIPTS = [
92
+ "Arabic",
93
+ "Devanagari",
94
+ "Bengali",
95
+ "Gurmukhi",
96
+ "Gujarati",
97
+ "Oriya",
98
+ "Tamil",
99
+ "Telugu",
100
+ "Kannada",
101
+ "Malayalam",
102
+ "Sinhala",
103
+ ];
104
+ const LINGUISTIC_LETTER = new RegExp(
105
+ `[${LINGUISTIC_SCRIPTS.map((script) => `\\p{Script=${script}}`).join("")}]`,
106
+ "u",
107
+ );
108
+ // Left side of an emoji joiner: a pictograph or a skin-tone modifier (a base
109
+ // emoji may carry a modifier before the joiner, e.g. a health-worker sequence).
110
+ const EMOJI_LEFT = /[\p{Extended_Pictographic}\p{Emoji_Modifier}]/u;
111
+ // Right side of an emoji joiner is always the next component's base pictograph.
112
+ const EMOJI_BASE = /\p{Extended_Pictographic}/u;
113
+
114
+ // Non-global single-char classifiers (CHECKS carry `g`, whose lastIndex is
115
+ // stateful across `.test`). carveStrip uses these to attribute each removed
116
+ // char to its CHECKS category so `found` names exactly what was stripped.
117
+ const CHECK_ONE = CHECKS.map(
118
+ ([label, re]) =>
119
+ /** @type {[string, RegExp]} */ ([label, new RegExp(re.source, "u")]),
120
+ );
121
+
122
+ /**
123
+ * The CHECKS category label a single code point belongs to, or null when it is
124
+ * not payload-capable (an ordinary visible character).
125
+ * @param {string} ch one code point
126
+ * @returns {string | null}
127
+ */
128
+ function classify(ch) {
129
+ for (const [label, re] of CHECK_ONE) if (re.test(ch)) return label;
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * True when `ch` (a ZWNJ/ZWJ) sits in an unambiguous linguistic context and so
135
+ * must be preserved rather than stripped. `prev`/`next` are the adjacent code
136
+ * points (single-code-point strings), or "" at a string boundary.
137
+ * @param {string} ch
138
+ * @param {string} prev
139
+ * @param {string} next
140
+ * @returns {boolean}
141
+ */
142
+ function isPreservedJoiner(ch, prev, next) {
143
+ const cp = ch.codePointAt(0);
144
+ if (cp !== ZWNJ && cp !== ZWJ) return false;
145
+ // prev/next are "" at a string boundary (see carveStrip), so a leading or
146
+ // trailing joiner matches neither script nor emoji class and falls through.
147
+ if (LINGUISTIC_LETTER.test(prev) && LINGUISTIC_LETTER.test(next)) return true;
148
+ // Emoji ZWJ sequences use ZWJ only, never ZWNJ.
149
+ if (cp === ZWJ && EMOJI_LEFT.test(prev) && EMOJI_BASE.test(next)) return true;
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Bulk strip (the common path: no ZWNJ/ZWJ present, so no carve-out can apply).
155
+ * A single regex pass removes every payload-capable char; `found` names the
156
+ * categories present via `.search` (which ignores the `g` lastIndex).
157
+ * @param {string} body
158
+ * @returns {{ cleaned: string, found: string[] }}
159
+ */
160
+ function bulkStrip(body) {
161
+ const found = CHECKS.filter(([, re]) => body.search(re) !== -1).map(
162
+ ([label]) => label,
163
+ );
164
+ return { cleaned: body.replace(STRIP, ""), found };
165
+ }
166
+
167
+ /**
168
+ * Carve-out strip (a ZWNJ/ZWJ is present): walk code points, preserving a join
169
+ * control only where isPreservedJoiner holds AND the text stays under the
170
+ * scatter floor — otherwise it is stripped like any other payload byte. `found`
171
+ * reports only categories actually removed, so a preserved joiner never makes
172
+ * the caller claim a strip that did not happen.
173
+ * @param {string} body
174
+ * @returns {{ cleaned: string, found: string[] }}
175
+ */
176
+ function carveStrip(body) {
177
+ // SCATTERED_THRESHOLD is the floor: past it, treat every invisible as payload
178
+ // regardless of context (threshold-evasion catch — over-strip beats under).
179
+ // Materialise codepoints once; count invisibles in a first pass so we know
180
+ // whether the carve-out applies before building the output string.
181
+ const cps = Array.from(body);
182
+ let invisCount = 0;
183
+ for (const ch of cps) if (classify(ch) !== null) invisCount++;
184
+ const allowCarveOut = invisCount < SCATTERED_THRESHOLD;
185
+ const foundLabels = new Set();
186
+ let out = "";
187
+ for (let i = 0; i < cps.length; i++) {
188
+ const ch = cps[i];
189
+ const label = classify(ch);
190
+ if (label === null) {
191
+ out += ch; // ordinary visible character
192
+ continue;
193
+ }
194
+ if (
195
+ allowCarveOut &&
196
+ isPreservedJoiner(ch, cps[i - 1] ?? "", cps[i + 1] ?? "")
197
+ ) {
198
+ out += ch;
199
+ continue;
200
+ }
201
+ foundLabels.add(label);
202
+ }
203
+ const found = CHECKS.filter(([label]) => foundLabels.has(label)).map(
204
+ ([label]) => label,
205
+ );
206
+ return { cleaned: out, found };
207
+ }
208
+
209
+ /**
210
+ * True when `body` holds at least one ZWNJ/ZWJ (so the carve-out may apply).
211
+ * @param {string} body
212
+ * @returns {boolean}
213
+ */
214
+ function hasJoinControl(body) {
215
+ return (
216
+ body.includes(String.fromCodePoint(ZWNJ)) ||
217
+ body.includes(String.fromCodePoint(ZWJ))
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Strip payload-capable invisible chars and report which categories were
223
+ * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
224
+ * interior BOMs and all soft hyphens (U+00AD) are stripped, since either can
225
+ * encode hidden instructions. ZWNJ/ZWJ survive only in a linguistic context
226
+ * (see the carve-out above). `found` names exactly the categories stripped, so
227
+ * a caller never warns about a strip the carve-out skipped.
228
+ * @param {string} text
229
+ * @returns {{ cleaned: string, found: string[] }}
230
+ */
231
+ export function stripInvisibleWithReport(text) {
232
+ const hasLeadingBom = text.charCodeAt(0) === 0xfeff;
233
+ const body = hasLeadingBom ? text.slice(1) : text;
234
+ const { cleaned, found } = hasJoinControl(body)
235
+ ? carveStrip(body)
236
+ : bulkStrip(body);
237
+ return { cleaned: hasLeadingBom ? BOM + cleaned : cleaned, found };
238
+ }
239
+
240
+ /**
241
+ * Strip payload-capable invisible chars (cleaned text only). See
242
+ * stripInvisibleWithReport for the BOM and ZWNJ/ZWJ carve-out semantics.
243
+ * @param {string} text
244
+ * @returns {string}
245
+ */
246
+ export function stripInvisible(text) {
247
+ return stripInvisibleWithReport(text).cleaned;
248
+ }