agent-sanitizer 2.20.2 → 2.21.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.20.2",
3
+ "version": "2.21.1",
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": {
@@ -53,6 +53,7 @@
53
53
  "@stryker-mutator/core": "^9.6.1",
54
54
  "@stryker-mutator/tap-runner": "^9.6.1",
55
55
  "@types/node": "25.9.1",
56
+ "acorn": "^8.18.0",
56
57
  "c8": "11.0.0",
57
58
  "esbuild": "0.28.1",
58
59
  "eslint": "10.4.0",
@@ -198,8 +199,8 @@
198
199
  ],
199
200
  "dependencies": {
200
201
  "agent-control-plane-core": "0.2.13",
201
- "namespace-guard": "0.20.0",
202
202
  "css-tree": "^3.2.1",
203
+ "namespace-guard": "0.20.0",
203
204
  "rehype-parse": "9.0.1",
204
205
  "remark-gfm": "4.0.1",
205
206
  "remark-parse": "11.0.0",
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 { CATEGORY, describeStripped } from "./invisible.mjs";
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
- /** @type {string[]} */ const found = [];
86
- /** @type {string[]} */ const warnings = [];
87
-
88
- const { cleaned: layer1, deAnsi, found: invisFound } = applyLayer1(text);
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
- const { sanitizeHtml, detectExfil } = await import("./html.mjs");
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) state.warnings.push(describeExfil(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[] }>}
@@ -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;