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/src/index.mjs ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Top-level convenience entry for agent-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 { CATEGORY, describeStripped } from "./invisible.mjs";
14
+ import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
15
+
16
+ // Layer 1 lives in the zero-dependency `./layer1.mjs`, shared verbatim with the
17
+ // tool-output pipeline (`./output`) and the Edit-repair rehydrator
18
+ // (`./rehydrate`) so every consumer derives the identical model-facing view.
19
+ export { applyLayer1, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
20
+
21
+ export {
22
+ stripInvisible,
23
+ stripInvisibleWithReport,
24
+ isSgrOnly,
25
+ STRIP,
26
+ SGR_RE,
27
+ CHECKS,
28
+ CATEGORY,
29
+ CATEGORY_LABELS,
30
+ LINGUISTIC_SCRIPTS,
31
+ VS,
32
+ BLANK_NON_CF,
33
+ LONG_RUN_RE,
34
+ LONG_RUN_THRESHOLD,
35
+ SCATTERED_THRESHOLD,
36
+ } from "./invisible.mjs";
37
+
38
+ // Layer 2/3 cheap pre-gates. Re-exported from the dependency-free `./gates.mjs`
39
+ // (not `./html.mjs`) so consumers can share the exact HTML-tag/markdown-link
40
+ // hints and secret-shape pre-gate without duplicating the regexes — and without
41
+ // pulling in the heavy remark/rehype graph that a re-export from `./html.mjs`
42
+ // would eagerly load on every root import.
43
+ export {
44
+ HTML_TAG_PRESENT,
45
+ MD_LINK_HINT,
46
+ SECRET_HINT,
47
+ SECRET_HINT_EXT,
48
+ matchesSecretHint,
49
+ } from "./gates.mjs";
50
+
51
+ /** @param {{ comments: number, hidden: number }} removed */
52
+ function describeRemoved(removed) {
53
+ const parts = [];
54
+ if (removed.comments > 0) parts.push(`${removed.comments} HTML comment(s)`);
55
+ if (removed.hidden > 0) parts.push(`${removed.hidden} hidden element(s)`);
56
+ return parts.join(", ");
57
+ }
58
+
59
+ /** @param {{ tags: Record<string, number>, dataSrc: number }} warned */
60
+ function describeWarned(warned) {
61
+ const parts = Object.entries(warned.tags).map(
62
+ ([tag, count]) => `${tag}×${count}`,
63
+ );
64
+ if (warned.dataSrc > 0) parts.push(`data: URI×${warned.dataSrc}`);
65
+ return parts.length > 0
66
+ ? `Preserved but reported (page source kept inspectable): ${parts.join(", ")}`
67
+ : "";
68
+ }
69
+
70
+ /**
71
+ * Sanitize untrusted text before any LLM sees it.
72
+ *
73
+ * Always runs Layer 1 (invisible-char + ANSI stripping, lone-surrogate
74
+ * normalization). When `html` is true, also lazy-loads the HTML layer to splice
75
+ * out human-invisible HTML (comments, hidden elements — Layer 2) and detect
76
+ * data-exfil-shaped URLs (Layer 3); the heavy remark/rehype dependency is only
77
+ * imported on that path. The exfil scan runs on the pre-splice text so a beacon
78
+ * URL hidden inside a `display:none` element is still reported, not buried by
79
+ * its own removal.
80
+ *
81
+ * `found` names the categories neutralized; `warnings` carries the
82
+ * operator-facing notices. `cleaned` is always a string, and a change only
83
+ * ever carries a warning (no silent suppression). `options` is optional and
84
+ * tolerates an explicit `null`/`undefined` (treated the same as omitted) —
85
+ * only a genuinely malformed `text` (not a string) throws, deliberately: a
86
+ * caller passing the wrong TYPE for `text` gets a clear, named error instead
87
+ * of an internal TypeError leaking implementation details (or a silent, wrong
88
+ * coercion of e.g. a number to a string).
89
+ * @param {string} text
90
+ * @param {{ html?: boolean } | null} [options]
91
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
92
+ */
93
+ export async function sanitize(text, options) {
94
+ if (typeof text !== "string")
95
+ throw new TypeError("sanitize(text, options): text must be a string");
96
+ const { html = false } = options ?? {};
97
+ /** @type {string[]} */ const found = [];
98
+ /** @type {string[]} */ const warnings = [];
99
+
100
+ const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
101
+ let cleaned = layer1;
102
+ if (invisFound.length > 0) {
103
+ found.push(...invisFound);
104
+ warnings.push(describeStripped(invisFound, deAnsi));
105
+ }
106
+
107
+ const wellFormed = cleaned.replace(LONE_SURROGATE_RE, "\uFFFD");
108
+ if (wellFormed !== cleaned) {
109
+ cleaned = wellFormed;
110
+ found.push(CATEGORY.LONE_SURROGATES);
111
+ warnings.push("Normalized lone UTF-16 surrogates");
112
+ }
113
+
114
+ if (!html) return { cleaned, found, warnings };
115
+
116
+ let sanitizeHtml, detectExfil;
117
+ /* c8 ignore start -- a rejected dynamic import of a module that ships in
118
+ this very package (not an optional peer dep) requires corrupting
119
+ node_modules or the filesystem to trigger; there's no clean way to force
120
+ this from a test without fragile module-loader mocking (Node's
121
+ mock.module needs --experimental-test-module-mocks, which isn't wired
122
+ into this repo's test script). Fail loudly with context if it ever fires. */
123
+ try {
124
+ ({ sanitizeHtml, detectExfil } = await import("./html.mjs"));
125
+ } catch (importErr) {
126
+ throw new Error(
127
+ "sanitize: failed to load HTML module (is the optional HTML dependency installed?)",
128
+ { cause: importErr },
129
+ );
130
+ }
131
+ /* c8 ignore stop */
132
+ // Scan for exfil URLs on the text BEFORE Layer 2 splices anything out — a
133
+ // beacon URL hidden in a comment or hidden element is more suspicious, not
134
+ // less, yet Layer 2 would otherwise remove it from view before the scan.
135
+ const preSplice = cleaned;
136
+
137
+ const layer2 = sanitizeHtml(cleaned);
138
+ if (layer2) {
139
+ if (layer2.text !== cleaned) {
140
+ cleaned = layer2.text;
141
+ if (layer2.removed.comments > 0) found.push(CATEGORY.HTML_COMMENTS);
142
+ if (layer2.removed.hidden > 0) found.push(CATEGORY.HIDDEN_HTML);
143
+ warnings.push(
144
+ `HTML sanitized: ${describeRemoved(layer2.removed)} replaced with placeholders`,
145
+ );
146
+ }
147
+ const preserved = describeWarned(layer2.warned);
148
+ if (preserved) warnings.push(preserved);
149
+ }
150
+
151
+ const threats = detectExfil(preSplice);
152
+ if (threats) {
153
+ found.push(CATEGORY.EXFIL_URLS);
154
+ const reasons = [
155
+ ...new Set(
156
+ threats.map(
157
+ (threat) =>
158
+ `${threat.isImage ? "image" : "link"} to ${threat.target}: ${threat.reason}`,
159
+ ),
160
+ ),
161
+ ];
162
+ warnings.push(`Exfil-shaped URLs detected: ${reasons.join("; ")}`);
163
+ }
164
+
165
+ return { cleaned, found, warnings };
166
+ }