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/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
|
+
}
|