agent-sanitizer 2.20.2 → 2.21.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/package.json +1 -1
- package/src/index.mjs +22 -73
- package/src/output.mjs +43 -9
- package/types/index.d.mts +9 -0
- package/types/output.d.mts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/index.mjs
CHANGED
|
@@ -6,19 +6,19 @@
|
|
|
6
6
|
* lazy-loads the heavier HTML layer (Layers 2 & 3) so the remark/rehype graph
|
|
7
7
|
* is only paid for by callers that ask for it.
|
|
8
8
|
*
|
|
9
|
+
* Layers 1-3 are NOT implemented here: this module is a facade over the single
|
|
10
|
+
* implementation in `./output.mjs` (`sanitizeText`). The two used to be
|
|
11
|
+
* hand-synced copies and had already drifted apart in their warning prose, so
|
|
12
|
+
* the layer bodies live in exactly one place and this file only translates the
|
|
13
|
+
* facade's option/result shape. Importing `./output.mjs` costs nothing at module
|
|
14
|
+
* scope — its graph is dependency-free and it lazy-loads `./html.mjs` on the
|
|
15
|
+
* same terms this facade used to.
|
|
16
|
+
*
|
|
9
17
|
* The low-level building blocks stay public via the `./invisible` and `./html`
|
|
10
18
|
* subpath entries; import those directly when you want a single layer without
|
|
11
19
|
* the convenience wrapper.
|
|
12
20
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
15
|
-
import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
|
|
16
|
-
import {
|
|
17
|
-
describeExfil,
|
|
18
|
-
describeHtmlSanitized,
|
|
19
|
-
describeWarned,
|
|
20
|
-
LONE_SURROGATE_WARNING,
|
|
21
|
-
} from "./warnings.mjs";
|
|
21
|
+
import { sanitizeText } from "./output.mjs";
|
|
22
22
|
|
|
23
23
|
// Layer 1 lives in the zero-dependency `./layer1.mjs`, shared verbatim with the
|
|
24
24
|
// tool-output pipeline (`./output`) and the Edit-repair rehydrator
|
|
@@ -74,6 +74,15 @@ export {
|
|
|
74
74
|
* caller passing the wrong TYPE for `text` gets a clear, named error instead
|
|
75
75
|
* of an internal TypeError leaking implementation details (or a silent, wrong
|
|
76
76
|
* coercion of e.g. a number to a string).
|
|
77
|
+
*
|
|
78
|
+
* The layer bodies live in `./output.mjs`; this is a facade over them, not a
|
|
79
|
+
* second implementation (see the module doc). It narrows `sanitizeText`'s result
|
|
80
|
+
* to the three fields this entry has always promised — `modified`/`sgrNote`
|
|
81
|
+
* describe the tool-output pipeline's banner, and `reveal` is produced only by
|
|
82
|
+
* options this facade does not expose. `html` selects Layers 2 AND 3 together
|
|
83
|
+
* here, which is the surface this entry has always had; `sanitizeText` takes
|
|
84
|
+
* them as separate flags for the tool-output pipeline, which needs Layer 3's
|
|
85
|
+
* detection without Layer 2's splice.
|
|
77
86
|
* @param {string} text
|
|
78
87
|
* @param {{ html?: boolean } | null} [options]
|
|
79
88
|
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
@@ -82,69 +91,9 @@ export async function sanitize(text, options) {
|
|
|
82
91
|
if (typeof text !== "string")
|
|
83
92
|
throw new TypeError("sanitize(text, options): text must be a string");
|
|
84
93
|
const { html = false } = options ?? {};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let cleaned = layer1;
|
|
90
|
-
if (invisFound.length > 0) {
|
|
91
|
-
found.push(...invisFound);
|
|
92
|
-
warnings.push(describeStripped(invisFound, deAnsi));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const wellFormed = cleaned.replace(LONE_SURROGATE_RE, "\uFFFD");
|
|
96
|
-
if (wellFormed !== cleaned) {
|
|
97
|
-
cleaned = wellFormed;
|
|
98
|
-
found.push(CATEGORY.LONE_SURROGATES);
|
|
99
|
-
warnings.push(LONE_SURROGATE_WARNING);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Layers 2 and 3 can only find something in text carrying an HTML tag or a
|
|
103
|
-
// markdown link, so the shared pre-gate decides whether the heavy
|
|
104
|
-
// remark/rehype graph is imported at all — the same gate `sanitizeText()`
|
|
105
|
-
// applies, so both entry points pay for (and skip) the import on exactly the
|
|
106
|
-
// same inputs.
|
|
107
|
-
if (!html || !needsMarkdownPipeline(cleaned))
|
|
108
|
-
return { cleaned, found, warnings };
|
|
109
|
-
|
|
110
|
-
let sanitizeHtml, detectExfil;
|
|
111
|
-
/* c8 ignore start -- a rejected dynamic import of a module that ships in
|
|
112
|
-
this very package (not an optional peer dep) requires corrupting
|
|
113
|
-
node_modules or the filesystem to trigger; there's no clean way to force
|
|
114
|
-
this from a test without fragile module-loader mocking (Node's
|
|
115
|
-
mock.module needs --experimental-test-module-mocks, which isn't wired
|
|
116
|
-
into this repo's test script). Fail loudly with context if it ever fires. */
|
|
117
|
-
try {
|
|
118
|
-
({ sanitizeHtml, detectExfil } = await import("./html.mjs"));
|
|
119
|
-
} catch (importErr) {
|
|
120
|
-
throw new Error(
|
|
121
|
-
"sanitize: failed to load HTML module (is the optional HTML dependency installed?)",
|
|
122
|
-
{ cause: importErr },
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
/* c8 ignore stop */
|
|
126
|
-
// Scan for exfil URLs on the text BEFORE Layer 2 splices anything out — a
|
|
127
|
-
// beacon URL hidden in a comment or hidden element is more suspicious, not
|
|
128
|
-
// less, yet Layer 2 would otherwise remove it from view before the scan.
|
|
129
|
-
const preSplice = cleaned;
|
|
130
|
-
|
|
131
|
-
const layer2 = sanitizeHtml(cleaned);
|
|
132
|
-
if (layer2) {
|
|
133
|
-
if (layer2.text !== cleaned) {
|
|
134
|
-
cleaned = layer2.text;
|
|
135
|
-
if (layer2.removed.comments > 0) found.push(CATEGORY.HTML_COMMENTS);
|
|
136
|
-
if (layer2.removed.hidden > 0) found.push(CATEGORY.HIDDEN_HTML);
|
|
137
|
-
warnings.push(describeHtmlSanitized(layer2.removed));
|
|
138
|
-
}
|
|
139
|
-
const preserved = describeWarned(layer2.warned);
|
|
140
|
-
if (preserved) warnings.push(preserved);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const threats = detectExfil(preSplice);
|
|
144
|
-
if (threats) {
|
|
145
|
-
found.push(CATEGORY.EXFIL_URLS);
|
|
146
|
-
warnings.push(describeExfil(threats));
|
|
147
|
-
}
|
|
148
|
-
|
|
94
|
+
const { cleaned, found, warnings } = await sanitizeText(text, {
|
|
95
|
+
html,
|
|
96
|
+
exfilScan: html,
|
|
97
|
+
});
|
|
149
98
|
return { cleaned, found, warnings };
|
|
150
99
|
}
|
package/src/output.mjs
CHANGED
|
@@ -141,9 +141,11 @@ function normalizeLoneSurrogates(text) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* @typedef {{ text: string, warnings: string[], modified: boolean, sgrNote: boolean }} PipelineState
|
|
144
|
+
* @typedef {{ text: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean }} PipelineState
|
|
145
145
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
146
|
-
* mutate it ONLY through {@link applyMutation}.
|
|
146
|
+
* mutate it ONLY through {@link applyMutation}. `found` is the machine-readable
|
|
147
|
+
* twin of `warnings`: the {@link CATEGORY} codes for whatever Layers 1-3
|
|
148
|
+
* neutralized or flagged.
|
|
147
149
|
*/
|
|
148
150
|
|
|
149
151
|
/**
|
|
@@ -273,16 +275,19 @@ export function deleteVerbatimSpans(text, spans) {
|
|
|
273
275
|
* with a terse note, not the WARNING prefix.
|
|
274
276
|
* @param {string} text
|
|
275
277
|
* @param {boolean} sgrCarveOut
|
|
276
|
-
* @returns {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean }}
|
|
278
|
+
* @returns {{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean }}
|
|
277
279
|
*/
|
|
278
280
|
function processLayer1(text, sgrCarveOut) {
|
|
279
281
|
/** @type {string[]} */
|
|
280
282
|
const warnings = [];
|
|
283
|
+
/** @type {string[]} */
|
|
284
|
+
const found = [];
|
|
281
285
|
let modified = false;
|
|
282
286
|
let sgrNote = false;
|
|
283
287
|
const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
|
|
284
288
|
let cleaned = layer1;
|
|
285
289
|
if (invisFound.length > 0) {
|
|
290
|
+
found.push(...invisFound);
|
|
286
291
|
modified = true;
|
|
287
292
|
// Display-only color with the carve-out enabled: the strip removed cosmetic
|
|
288
293
|
// styling and nothing else (found is exactly [ANSI], so zero invisible
|
|
@@ -304,9 +309,10 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
304
309
|
cleaned = wellFormed;
|
|
305
310
|
modified = true;
|
|
306
311
|
sgrNote = false;
|
|
312
|
+
found.push(CATEGORY.LONE_SURROGATES);
|
|
307
313
|
warnings.push(LONE_SURROGATE_WARNING);
|
|
308
314
|
}
|
|
309
|
-
return { cleaned, warnings, modified, sgrNote };
|
|
315
|
+
return { cleaned, found, warnings, modified, sgrNote };
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
/**
|
|
@@ -327,7 +333,23 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
327
333
|
let reveal;
|
|
328
334
|
if ((!html && !exfilScan) || !needsMarkdownPipeline(inputText))
|
|
329
335
|
return undefined;
|
|
330
|
-
|
|
336
|
+
let sanitizeHtml, detectExfil;
|
|
337
|
+
/* c8 ignore start -- a rejected dynamic import of a module that ships in
|
|
338
|
+
this very package (not an optional peer dep) requires corrupting
|
|
339
|
+
node_modules or the filesystem to trigger; there's no clean way to force
|
|
340
|
+
this from a test without fragile module-loader mocking (Node's
|
|
341
|
+
mock.module needs --experimental-test-module-mocks, which isn't wired
|
|
342
|
+
into this repo's test script). Fail loudly with context if it ever fires. */
|
|
343
|
+
try {
|
|
344
|
+
({ sanitizeHtml, detectExfil } = await import("./html.mjs"));
|
|
345
|
+
} catch (importErr) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
"agent-sanitizer: failed to load ./html.mjs, so Layers 2/3 could not run " +
|
|
348
|
+
"(are the package's remark/rehype dependencies installed?)",
|
|
349
|
+
{ cause: importErr },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
/* c8 ignore stop */
|
|
331
353
|
// Layer 2 — strips what a rendered page would not show (comments, hidden
|
|
332
354
|
// elements); scripting/resource tags preserved+reported.
|
|
333
355
|
if (html) {
|
|
@@ -336,6 +358,9 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
336
358
|
if (layer2.text !== state.text) {
|
|
337
359
|
reveal = state.text;
|
|
338
360
|
applyMutation(state, layer2.text);
|
|
361
|
+
if (layer2.removed.comments > 0)
|
|
362
|
+
state.found.push(CATEGORY.HTML_COMMENTS);
|
|
363
|
+
if (layer2.removed.hidden > 0) state.found.push(CATEGORY.HIDDEN_HTML);
|
|
339
364
|
state.warnings.push(describeHtmlSanitized(layer2.removed));
|
|
340
365
|
}
|
|
341
366
|
const preserved = describeWarned(layer2.warned);
|
|
@@ -348,7 +373,10 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
|
|
|
348
373
|
// suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
|
|
349
374
|
if (exfilScan) {
|
|
350
375
|
const threats = detectExfil(inputText);
|
|
351
|
-
if (threats)
|
|
376
|
+
if (threats) {
|
|
377
|
+
state.found.push(CATEGORY.EXFIL_URLS);
|
|
378
|
+
state.warnings.push(describeExfil(threats));
|
|
379
|
+
}
|
|
352
380
|
}
|
|
353
381
|
return reveal;
|
|
354
382
|
}
|
|
@@ -421,18 +449,23 @@ async function vetStageValue(text, redact, warnings, label) {
|
|
|
421
449
|
* through {@link runRedact}, so a layer cannot re-establish some of the
|
|
422
450
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
423
451
|
* object has traversed Layer 4.
|
|
452
|
+
*
|
|
453
|
+
* `found` is the machine-readable twin of `warnings` — the {@link CATEGORY}
|
|
454
|
+
* codes for what Layers 1-3 neutralized or flagged, in the order the layers ran.
|
|
455
|
+
* Layers 4 and 5 have no category codes (their findings are the injected seam's
|
|
456
|
+
* own vocabulary), so they contribute warnings only.
|
|
424
457
|
* @param {string} text
|
|
425
458
|
* @param {SanitizeTextOptions} [options]
|
|
426
|
-
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
459
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
427
460
|
*/
|
|
428
461
|
export async function sanitizeText(text, options = {}) {
|
|
429
462
|
const { redact, filterInjection, sgrCarveOut = false } = options;
|
|
430
|
-
const { warnings, cleaned, modified, sgrNote } = processLayer1(
|
|
463
|
+
const { warnings, found, cleaned, modified, sgrNote } = processLayer1(
|
|
431
464
|
text,
|
|
432
465
|
sgrCarveOut,
|
|
433
466
|
);
|
|
434
467
|
/** @type {PipelineState} */
|
|
435
|
-
const state = { text: cleaned, warnings, modified, sgrNote };
|
|
468
|
+
const state = { text: cleaned, found, warnings, modified, sgrNote };
|
|
436
469
|
|
|
437
470
|
const revealText = await applyMarkdownPipeline(state, options);
|
|
438
471
|
|
|
@@ -483,6 +516,7 @@ export async function sanitizeText(text, options = {}) {
|
|
|
483
516
|
);
|
|
484
517
|
return {
|
|
485
518
|
cleaned: state.text,
|
|
519
|
+
found: state.found,
|
|
486
520
|
warnings: state.warnings,
|
|
487
521
|
modified: state.modified,
|
|
488
522
|
sgrNote: state.sgrNote,
|
package/types/index.d.mts
CHANGED
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
* caller passing the wrong TYPE for `text` gets a clear, named error instead
|
|
18
18
|
* of an internal TypeError leaking implementation details (or a silent, wrong
|
|
19
19
|
* coercion of e.g. a number to a string).
|
|
20
|
+
*
|
|
21
|
+
* The layer bodies live in `./output.mjs`; this is a facade over them, not a
|
|
22
|
+
* second implementation (see the module doc). It narrows `sanitizeText`'s result
|
|
23
|
+
* to the three fields this entry has always promised — `modified`/`sgrNote`
|
|
24
|
+
* describe the tool-output pipeline's banner, and `reveal` is produced only by
|
|
25
|
+
* options this facade does not expose. `html` selects Layers 2 AND 3 together
|
|
26
|
+
* here, which is the surface this entry has always had; `sanitizeText` takes
|
|
27
|
+
* them as separate flags for the tool-output pipeline, which needs Layer 3's
|
|
28
|
+
* detection without Layer 2's splice.
|
|
20
29
|
* @param {string} text
|
|
21
30
|
* @param {{ html?: boolean } | null} [options]
|
|
22
31
|
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
package/types/output.d.mts
CHANGED
|
@@ -49,12 +49,18 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
49
49
|
* through {@link runRedact}, so a layer cannot re-establish some of the
|
|
50
50
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
51
51
|
* object has traversed Layer 4.
|
|
52
|
+
*
|
|
53
|
+
* `found` is the machine-readable twin of `warnings` — the {@link CATEGORY}
|
|
54
|
+
* codes for what Layers 1-3 neutralized or flagged, in the order the layers ran.
|
|
55
|
+
* Layers 4 and 5 have no category codes (their findings are the injected seam's
|
|
56
|
+
* own vocabulary), so they contribute warnings only.
|
|
52
57
|
* @param {string} text
|
|
53
58
|
* @param {SanitizeTextOptions} [options]
|
|
54
|
-
* @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
59
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
55
60
|
*/
|
|
56
61
|
export function sanitizeText(text: string, options?: SanitizeTextOptions): Promise<{
|
|
57
62
|
cleaned: string;
|
|
63
|
+
found: string[];
|
|
58
64
|
warnings: string[];
|
|
59
65
|
modified: boolean;
|
|
60
66
|
sgrNote: boolean;
|
|
@@ -183,10 +189,13 @@ export type Layer5Result = {
|
|
|
183
189
|
};
|
|
184
190
|
/**
|
|
185
191
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
186
|
-
* mutate it ONLY through {@link applyMutation}.
|
|
192
|
+
* mutate it ONLY through {@link applyMutation}. `found` is the machine-readable
|
|
193
|
+
* twin of `warnings`: the {@link CATEGORY} codes for whatever Layers 1-3
|
|
194
|
+
* neutralized or flagged.
|
|
187
195
|
*/
|
|
188
196
|
export type PipelineState = {
|
|
189
197
|
text: string;
|
|
198
|
+
found: string[];
|
|
190
199
|
warnings: string[];
|
|
191
200
|
modified: boolean;
|
|
192
201
|
sgrNote: boolean;
|