agent-sanitizer 2.63.0 → 2.63.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/src/output.mjs CHANGED
@@ -20,9 +20,9 @@
20
20
  * filter can at most remove legitimate content — it can never inject new bytes
21
21
  * into the model's view. A consumer running a live LLM filter wires it here.
22
22
  * Because a span deletion joins the bytes on either side of the deleted span,
23
- * Layer 4 (`redact`) is re-run on the post-deletion text whenever Layer 5
24
- * actually removes something, so a secret that a deletion reconstitutes is
25
- * still caught before this function returns.
23
+ * Layers 3 (`exfilScan`) and 4 (`redact`) are re-run on the post-deletion text
24
+ * whenever Layer 5 actually removes something, so a URL or secret that a
25
+ * deletion assembles is still caught before this function returns.
26
26
  */
27
27
  import {
28
28
  CATEGORY,
@@ -43,6 +43,7 @@ import {
43
43
  HTML_UNPARSEABLE_WARNING,
44
44
  describeWarned,
45
45
  LONE_SURROGATE_WARNING,
46
+ UNSCANNABLE_WARNING,
46
47
  } from "./warnings.mjs";
47
48
  import {
48
49
  finding,
@@ -145,9 +146,9 @@ function errMessage(err) {
145
146
  */
146
147
 
147
148
  /**
148
- * @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean, unreportedChange: boolean }} PipelineState
149
+ * @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean }} PipelineState
149
150
  * The running state of one {@link sanitizeText} call. Layers read `text` and
150
- * mutate it ONLY through {@link applyMutation}. Findings carry their own
151
+ * mutate it ONLY through {@link mutate}. Findings carry their own
151
152
  * severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
152
153
  * single exit, so no layer can push into the wrong list. `found` is the
153
154
  * machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
@@ -155,49 +156,36 @@ function errMessage(err) {
155
156
  */
156
157
 
157
158
  /**
158
- * THE only way a layer may change `state.text`. Every byte mutation invalidates
159
- * the same three stage invariants, so all three are re-established in one place
160
- * rather than at each mutation site:
159
+ * THE only way a layer may change `state.text`; it re-establishes every stage
160
+ * invariant a byte change breaks, in one place:
161
161
  *
162
- * 1. lone surrogates are normalized. The module doc promises this "always",
163
- * but each mutation can BREAK it again: a Layer-5 span deletion joins the
164
- * bytes on either side of the deleted span and can leave a lone surrogate
165
- * the model renders as a broken glyph and the redactor reads as U+FFFD.
166
- * Repairing it inside whichever layer happens to need it — e.g. only in
167
- * the post-span-deletion re-redact, so it runs only when a redactor is
168
- * configured — would make an invariant of Layer 1 conditional on an
169
- * unrelated option.
162
+ * 1. lone surrogates are normalized: the module doc promises this "always",
163
+ * but a Layer-5 span deletion joins the bytes around it and can strand
164
+ * one. No warning is pushed for it: the mutation that created it reports
165
+ * itself.
170
166
  * 2. `modified` is set — the caller's "bytes changed" banner.
171
- * 3. `unreportedChange` is set. A mutation that pushed no finding — a Layer-5
172
- * span deletion whose filter returned no warning code — is a change the
173
- * caller was never told about, and the returned `sgrNote` ("nothing here
174
- * rose above a note") must not invite a quiet banner over one. A mutation
175
- * that DID report itself is covered by its own WARNING, so this flag only
176
- * ever costs a note that would have been misleading.
167
+ * 3. `report` is pushed. It is required and a Finding cannot carry an empty
168
+ * message, so an unreported byte change is unconstructible — what
169
+ * `sgrNote` rests on.
177
170
  *
178
- * Callers decide WHETHER a mutation happened (each layer already knows: a
179
- * changed splice output, a redactor finding, a removed span) and call this with
180
- * the new bytes; a no-op call would falsely set `modified`.
181
- *
182
- * No warning is pushed for the normalization: the mutation that created the
183
- * lone surrogate reported itself, and a second "Normalized lone UTF-16
184
- * surrogates" line would describe the library repairing its own splice rather
185
- * than a finding about the input.
171
+ * Callers decide WHETHER a mutation happened; a no-op call would falsely set
172
+ * `modified`.
186
173
  * @param {PipelineState} state
187
- * @param {string} nextText
174
+ * @param {string} text
175
+ * @param {import("./severity.mjs").Finding} report
188
176
  * @returns {void}
189
177
  */
190
- function applyMutation(state, nextText) {
191
- state.text = normalizeLoneSurrogates(nextText);
178
+ function mutate(state, text, report) {
179
+ state.text = normalizeLoneSurrogates(text);
192
180
  state.modified = true;
193
- state.unreportedChange = true;
181
+ state.findings.push(report);
194
182
  }
195
183
 
196
184
  /**
197
- * Run Layer 4 (`redact`) over the state's current text and fold any finding
198
- * back in. The single Layer-4 invocation site FOR THE PIPELINE STATE: the first
199
- * pass and the re-scan after a Layer-5 span deletion are the same call, so they
200
- * share one fail-closed handling, warning prose and post-redaction invariant.
185
+ * Run Layer 4 (`options.redact`, when set) over the state's current text and
186
+ * fold any finding back in. The single Layer-4 invocation site FOR THE PIPELINE
187
+ * STATE: the first pass and the re-run after a span deletion are the same call,
188
+ * so they share one fail-closed handling, warning prose and invariants.
201
189
  *
202
190
  * One other site runs Layer 4 deliberately: {@link vetStageValue}, which vets a
203
191
  * stage value on its way out and has no `PipelineState` to fold a finding into.
@@ -210,10 +198,12 @@ function applyMutation(state, nextText) {
210
198
  * the throw is rethrown wrapped and the caller suppresses the output rather than
211
199
  * emitting an unvetted value with a warning.
212
200
  * @param {PipelineState} state
213
- * @param {(text: string) => Promise<RedactResult|null> | (RedactResult|null)} redact
201
+ * @param {SanitizeTextOptions} options
214
202
  * @returns {Promise<void>}
215
203
  */
216
- async function runRedact(state, redact) {
204
+ async function runRedact(state, options) {
205
+ const { redact } = options;
206
+ if (!redact) return;
217
207
  /** @type {RedactResult|null} */
218
208
  let secrets;
219
209
  try {
@@ -226,10 +216,11 @@ async function runRedact(state, redact) {
226
216
  );
227
217
  }
228
218
  if (!secrets) return;
229
- applyMutation(state, secrets.text);
230
219
  // Always a WARNING: a secret reached this output, and the caller-supplied
231
220
  // `note` (which credential, from where) is the part an operator acts on.
232
- state.findings.push(
221
+ mutate(
222
+ state,
223
+ secrets.text,
233
224
  warning(
234
225
  `API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
235
226
  ),
@@ -380,6 +371,110 @@ function processLayer1(text, sgrCarveOut) {
380
371
  return { cleaned, found, findings, modified };
381
372
  }
382
373
 
374
+ /**
375
+ * Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
376
+ * whole document in ONE synchronous call, so nothing interrupts it, and a host
377
+ * that kills the overrun hook shows the RAW text. Called before EACH parse, and
378
+ * only after a pre-gate passed, so a declined call costs no time. Fails closed,
379
+ * as Layer 4 does.
380
+ * @param {Deadline | undefined} deadline
381
+ * @returns {void}
382
+ */
383
+ function refuseIfSpent(deadline) {
384
+ if (deadline && deadline.remainingMs() <= 0)
385
+ throw new Error(
386
+ "CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
387
+ "and exfil-URL layers finished, so this text was not fully checked. " +
388
+ "Failing closed — tool output suppressed.",
389
+ );
390
+ }
391
+
392
+ /**
393
+ * The heavy HTML layer (./html.mjs), loaded on first use. The awaited load can
394
+ * spend the budget the caller's pre-check saw, and every caller goes straight
395
+ * on to an uninterruptible parse, so the deadline is re-checked HERE.
396
+ * @param {Deadline | undefined} deadline
397
+ * @returns {Promise<typeof import("./html.mjs")>}
398
+ */
399
+ async function loadHtmlLayer(deadline) {
400
+ let layer;
401
+ /* c8 ignore start -- a rejected import of a module this package ships needs a
402
+ corrupted install, and forcing one needs --experimental-test-module-mocks,
403
+ which this repo's test script does not wire. Fail loudly if it fires. */
404
+ try {
405
+ layer = await import("./html.mjs");
406
+ } catch (importErr) {
407
+ throw new Error(
408
+ "agent-sanitizer: failed to load ./html.mjs, so Layers 2/3 could not run " +
409
+ "(are the package's remark/rehype dependencies installed?)",
410
+ { cause: importErr },
411
+ );
412
+ }
413
+ /* c8 ignore stop */
414
+ refuseIfSpent(deadline);
415
+ return layer;
416
+ }
417
+
418
+ /**
419
+ * Layer 3 over `text`, folded into `state`: exfil-URL and confusable-host
420
+ * detection only — the URLs stay intact, the model is told not to use them.
421
+ * @param {PipelineState} state
422
+ * @param {typeof import("./html.mjs")} htmlLayer
423
+ * @param {string} text
424
+ * @param {{ flagDigestValues?: boolean, deadline?: Deadline }} options
425
+ * @returns {void}
426
+ */
427
+ function applyLayer3(state, htmlLayer, text, { flagDigestValues, deadline }) {
428
+ refuseIfSpent(deadline);
429
+ const exfil = htmlLayer.detectExfil(text, { flagDigestValues });
430
+ refuseIfSpent(deadline);
431
+ const hosts = htmlLayer.detectConfusableHosts(text);
432
+ /** @type {Array<[string, import("./severity.mjs").Finding]>} */
433
+ const reports = [];
434
+ // An overflowed scan returns only its sentinel and names no URL, so it is its
435
+ // own finding: `exfil-urls`/`confusable-host` would claim a URL exists.
436
+ const overflowed = (
437
+ /** @type {Array<{ unscannable?: true }> | null} */ list,
438
+ ) => list?.some((threat) => threat.unscannable) ?? false;
439
+ if (overflowed(exfil) || overflowed(hosts))
440
+ reports.push([CATEGORY.UNSCANNABLE, warning(UNSCANNABLE_WARNING)]);
441
+ // Severity tracks who does the fetching. An auto-fetched target (image,
442
+ // stylesheet, form action, meta refresh) exfiltrates the moment the content
443
+ // renders: a WARNING. A plain LINK needs the model to follow it, and the
444
+ // sentence it is reported in is the instruction not to: a NOTE. The loudest
445
+ // member wins, since they share one line.
446
+ if (exfil && !overflowed(exfil))
447
+ reports.push([
448
+ CATEGORY.EXFIL_URLS,
449
+ finding(
450
+ exfil.some((threat) => threat.autoFetched),
451
+ describeExfil(exfil),
452
+ ),
453
+ ]);
454
+ // Confusable hosts ride the same scan flag but are a SEPARATE finding: a
455
+ // look-alike domain needs no exfil-shaped query to be the whole attack, and
456
+ // the two say different things to a reader. Tier per ./confusable-host.mjs,
457
+ // and the loudest member wins the shared line.
458
+ if (hosts && !overflowed(hosts))
459
+ reports.push([
460
+ CATEGORY.CONFUSABLE_HOST,
461
+ finding(
462
+ hosts.some((threat) => threat.severity === SEVERITY.WARNING),
463
+ describeConfusableHosts(hosts),
464
+ ),
465
+ ]);
466
+ // A re-run after a span deletion re-finds the same hits: report each once.
467
+ for (const [code, report] of reports) {
468
+ if (!state.found.includes(code)) state.found.push(code);
469
+ if (
470
+ !state.findings.some(
471
+ (f) => f.severity === report.severity && f.message === report.message,
472
+ )
473
+ )
474
+ state.findings.push(report);
475
+ }
476
+ }
477
+
383
478
  /**
384
479
  * Layers 2+3: HTML sanitisation (`html`) and exfil-URL detection (`exfilScan`),
385
480
  * folded into `state`. Returns the pre-splice text when Layer 2 removed bytes so
@@ -393,13 +488,11 @@ function processLayer1(text, sgrCarveOut) {
393
488
  * vet them before they leave. The transform itself stays pure — the caller owns
394
489
  * any persistence.
395
490
  * @param {PipelineState} state
396
- * @param {{ html?: boolean, exfilScan?: boolean, flagDigestValues?: boolean, deadline?: Deadline }} options
491
+ * @param {SanitizeTextOptions} options
397
492
  * @returns {Promise<{ reveal: string | undefined, splices: Array<{ placeholder: string, original: string }> }>}
398
493
  */
399
- async function applyMarkdownPipeline(
400
- state,
401
- { html, exfilScan, flagDigestValues, deadline },
402
- ) {
494
+ async function applyMarkdownPipeline(state, options) {
495
+ const { html, exfilScan, deadline } = options;
403
496
  const inputText = state.text;
404
497
  /** @type {string | undefined} */
405
498
  let reveal;
@@ -412,43 +505,13 @@ async function applyMarkdownPipeline(
412
505
  const runLayer2 = Boolean(html) && needsMarkdownPipeline(inputText);
413
506
  const runLayer3 = Boolean(exfilScan) && needsUrlScan(inputText);
414
507
  if (!runLayer2 && !runLayer3) return { reveal: undefined, splices };
415
- // INVARIANT: this refusal stops a layer below from STARTING with no budget
416
- // left. Each parses the whole document in ONE synchronous call, so nothing
417
- // interrupts it, and a host that kills the overrun hook shows the RAW text.
418
- // Called before EACH parse: Layer 2 spends the budget Layer 3 then runs on.
419
- // After the pre-gate: a declined call costs no time. Fail closed, as Layer 4.
420
- const refuseIfSpent = () => {
421
- if (deadline && deadline.remainingMs() <= 0)
422
- throw new Error(
423
- "CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
424
- "and exfil-URL layers finished, so this text was not fully checked. " +
425
- "Failing closed — tool output suppressed.",
426
- );
427
- };
428
- refuseIfSpent();
429
- let sanitizeHtml, detectExfil, detectConfusableHosts;
430
- /* c8 ignore start -- a rejected dynamic import of a module that ships in
431
- this very package (not an optional peer dep) requires corrupting
432
- node_modules or the filesystem to trigger; there's no clean way to force
433
- this from a test without fragile module-loader mocking (Node's
434
- mock.module needs --experimental-test-module-mocks, which isn't wired
435
- into this repo's test script). Fail loudly with context if it ever fires. */
436
- try {
437
- ({ sanitizeHtml, detectExfil, detectConfusableHosts } =
438
- await import("./html.mjs"));
439
- } catch (importErr) {
440
- throw new Error(
441
- "agent-sanitizer: failed to load ./html.mjs, so Layers 2/3 could not run " +
442
- "(are the package's remark/rehype dependencies installed?)",
443
- { cause: importErr },
444
- );
445
- }
446
- /* c8 ignore stop */
508
+ refuseIfSpent(deadline);
509
+ const htmlLayer = await loadHtmlLayer(deadline);
447
510
  // Layer 2 — strips what a rendered page would not show (comments, hidden
448
511
  // elements); scripting/resource tags preserved+reported. Each cut leaves a
449
512
  // keyed placeholder whose original bytes ride out in `splices`.
450
513
  if (runLayer2) {
451
- const layer2 = sanitizeHtml(state.text);
514
+ const layer2 = htmlLayer.sanitizeHtml(state.text);
452
515
  if (layer2) {
453
516
  if (layer2.text !== state.text) {
454
517
  reveal = state.text;
@@ -456,7 +519,6 @@ async function applyMarkdownPipeline(
456
519
  // point into THIS stage's text, which Layers 4/5 may still mutate.
457
520
  for (const { placeholder, original } of layer2.splices)
458
521
  splices.push({ placeholder, original });
459
- applyMutation(state, layer2.text);
460
522
  if (layer2.removed.comments > 0)
461
523
  state.found.push(CATEGORY.HTML_COMMENTS);
462
524
  if (layer2.removed.hidden > 0) state.found.push(CATEGORY.HIDDEN_HTML);
@@ -466,7 +528,9 @@ async function applyMarkdownPipeline(
466
528
  // without the reveal sidecar. The unparseable fail-closed path withheld
467
529
  // the WHOLE output, not a spliced span, so it gets its own sentence
468
530
  // rather than a misleading "1 hidden element(s) replaced".
469
- state.findings.push(
531
+ mutate(
532
+ state,
533
+ layer2.text,
470
534
  warning(
471
535
  layer2.unparseable
472
536
  ? HTML_UNPARSEABLE_WARNING
@@ -482,48 +546,31 @@ async function applyMarkdownPipeline(
482
546
  if (preserved) state.findings.push(note(preserved));
483
547
  }
484
548
  }
485
- // Layer 3 — detection only: the URLs stay intact, the model is told not to
486
- // use them. Scan the ORIGINAL text, not the Layer-2 splice output: a beacon
487
- // URL hidden inside a display:none element or an HTML comment is MORE
488
- // suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
489
- if (runLayer3) {
490
- refuseIfSpent();
491
- const threats = detectExfil(inputText, { flagDigestValues });
492
- // Severity tracks who does the fetching. An auto-fetched target — an image,
493
- // a stylesheet, a form action, a meta refresh — exfiltrates the moment the
494
- // content renders, with nobody deciding anything: a WARNING. A plain LINK
495
- // cannot exfiltrate unless the model chooses to follow it, and the sentence
496
- // it is reported in is precisely the instruction not to, so it is a NOTE.
497
- // One auto-fetched threat raises the whole finding — the loudest member
498
- // wins, since they share one line.
499
- if (threats) {
500
- state.found.push(CATEGORY.EXFIL_URLS);
501
- state.findings.push(
502
- finding(
503
- threats.some((threat) => threat.autoFetched),
504
- describeExfil(threats),
505
- ),
506
- );
507
- }
508
- // Confusable hosts ride the same scan flag but are a SEPARATE finding: a
509
- // look-alike domain needs no exfil-shaped query to be the whole attack, and
510
- // the two say different things to a reader. Tier per ./confusable-host.mjs,
511
- // and the loudest member wins the shared line.
512
- refuseIfSpent();
513
- const confusable = detectConfusableHosts(inputText);
514
- if (confusable) {
515
- state.found.push(CATEGORY.CONFUSABLE_HOST);
516
- state.findings.push(
517
- finding(
518
- confusable.some((threat) => threat.severity === SEVERITY.WARNING),
519
- describeConfusableHosts(confusable),
520
- ),
521
- );
522
- }
523
- }
549
+ // Scan the ORIGINAL text, not the Layer-2 splice output: a beacon URL hidden
550
+ // inside a display:none element or an HTML comment is MORE suspicious, not
551
+ // less, yet Layer 2 has already removed it from `cleaned`.
552
+ if (runLayer3) applyLayer3(state, htmlLayer, inputText, options);
524
553
  return { reveal, splices };
525
554
  }
526
555
 
556
+ /**
557
+ * Layer 3 over the state's CURRENT text, when `options.exfilScan` is set and
558
+ * its gate opens: the re-run after a Layer-5 span deletion. Layer 2 has none —
559
+ * it hides what the ORIGINAL rendering hid, which a later mutation cannot change.
560
+ * @param {PipelineState} state
561
+ * @param {SanitizeTextOptions} options
562
+ * @returns {Promise<void>}
563
+ */
564
+ async function rescanUrls(state, options) {
565
+ if (!options.exfilScan || !needsUrlScan(state.text)) return;
566
+ applyLayer3(
567
+ state,
568
+ await loadHtmlLayer(options.deadline),
569
+ state.text,
570
+ options,
571
+ );
572
+ }
573
+
527
574
  /**
528
575
  * Vet a pipeline STAGE value on its way out of {@link sanitizeText}. Only
529
576
  * `cleaned` traverses every layer; anything else a caller is handed (today the
@@ -542,10 +589,10 @@ async function applyMarkdownPipeline(
542
589
  * runs on attacker-influenced content. `label` names the withheld field in that
543
590
  * warning and comes from the call site below, never from a seam.
544
591
  *
545
- * Normalizes the redactor's output for the same reason {@link applyMutation}
592
+ * Normalizes the redactor's output for the same reason {@link mutate}
546
593
  * does — a redaction that cuts between the halves of an astral pair strands a
547
594
  * code unit, and this string is persisted and read back. It cannot USE
548
- * `applyMutation`: that folds into the `PipelineState`, and a stage value is not
595
+ * `mutate`: that folds into the `PipelineState`, and a stage value is not
549
596
  * the pipeline text — setting `modified`/`sgrNote` from a sidecar's redaction
550
597
  * would describe `cleaned`, which this call never touches. Only the
551
598
  * normalization is shared. `text` arrives post-Layer-1, so the no-redactor and
@@ -609,7 +656,7 @@ async function vetStageValue(text, redact, findings, label) {
609
656
  * and one that cannot be vetted is WITHHELD (dropped from the array) under the
610
657
  * same doctrine — Layer 4 never saw pre-splice text.
611
658
  *
612
- * Every byte mutation goes through {@link applyMutation} and every Layer-4 call
659
+ * Every byte mutation goes through {@link mutate} and every Layer-4 call
613
660
  * through {@link runRedact}, so a layer cannot re-establish some of the
614
661
  * post-mutation invariants and forget the rest, and every string in the returned
615
662
  * object has traversed Layer 4.
@@ -636,19 +683,13 @@ export async function sanitizeText(text, options = {}) {
636
683
  sgrCarveOut,
637
684
  );
638
685
  /** @type {PipelineState} */
639
- const state = {
640
- text: cleaned,
641
- found,
642
- findings,
643
- modified,
644
- unreportedChange: false,
645
- };
686
+ const state = { text: cleaned, found, findings, modified };
646
687
 
647
688
  const { reveal: revealText, splices: stageSplices } =
648
689
  await applyMarkdownPipeline(state, options);
649
690
 
650
691
  // Layer 4 — fail closed (see runRedact).
651
- if (redact) await runRedact(state, redact);
692
+ await runRedact(state, options);
652
693
 
653
694
  // Layer 5 — secure span-deletion slot (see module doc). A warning-only result
654
695
  // flags without changing bytes; only a deleted span sets `modified`. Awaited
@@ -658,23 +699,39 @@ export async function sanitizeText(text, options = {}) {
658
699
  if (filterInjection) {
659
700
  const res = await filterInjection(state.text);
660
701
  if (res) {
661
- if (res.removeSpans && res.removeSpans.length > 0) {
662
- const out = deleteVerbatimSpans(state.text, res.removeSpans);
663
- if (out.removed > 0) {
664
- applyMutation(state, out.text);
665
- // A span deletion joins the bytes on either side of it, which can
666
- // reconstitute a secret Layer 4 never saw intact (it ran on the
667
- // ORIGINAL text, before the join). Re-vet the post-deletion text so a
668
- // compromised filter can still only ever REMOVE legitimate content,
669
- // never smuggle an unvetted secret through by splicing around it.
670
- if (redact) await runRedact(state, redact);
671
- }
672
- }
673
702
  // A filter warning is a library-owned ENUM CODE, mapped here to its fixed
674
703
  // message; free text is refused (throws) so no filter-supplied byte ever
675
704
  // reaches the model-facing context. `null`/`undefined` means no warning.
676
- if (res.warning != null)
677
- state.findings.push(warning(mapFilterWarning(res.warning)));
705
+ // Mapped BEFORE any deletion, so an invalid code fails the call whether
706
+ // or not a span matched.
707
+ const filterWarning =
708
+ res.warning == null ? undefined : mapFilterWarning(res.warning);
709
+ const removed =
710
+ res.removeSpans && res.removeSpans.length > 0
711
+ ? deleteVerbatimSpans(state.text, res.removeSpans)
712
+ : { text: state.text, removed: 0 };
713
+ if (removed.removed > 0) {
714
+ // Reported in the library's own words whatever code the filter sent
715
+ // (none would be a silent byte change). A deletion JOINS the bytes
716
+ // around it — `htt<span>ps://аpple.com` becomes a URL, a split key a
717
+ // secret — so Layers 3 and 4 re-run: a compromised filter can only
718
+ // REMOVE. (A splice or redaction leaves a placeholder, which joins none.)
719
+ mutate(
720
+ state,
721
+ removed.text,
722
+ warning(FILTER_WARNING_LABELS[FILTER_WARNING.SPANS_REMOVED]),
723
+ );
724
+ await rescanUrls(state, options);
725
+ await runRedact(state, options);
726
+ }
727
+ // The deletion's own report already says spans were removed, and
728
+ // "flagged (content not modified)" would contradict it.
729
+ const subsumed =
730
+ removed.removed > 0 &&
731
+ (res.warning === FILTER_WARNING.SPANS_REMOVED ||
732
+ res.warning === FILTER_WARNING.FILTER_FLAGGED);
733
+ if (filterWarning !== undefined && !subsumed)
734
+ state.findings.push(warning(filterWarning));
678
735
  }
679
736
  }
680
737
 
@@ -720,9 +777,9 @@ export async function sanitizeText(text, options = {}) {
720
777
  // published field for a wording win is a breaking change) but derived
721
778
  // rather than tracked: "nothing here rose above a note". The inert-ANSI
722
779
  // strip is simply the most common way to end up note-only, not the only
723
- // one this field now covers.
724
- sgrNote:
725
- notes.length > 0 && warnings.length === 0 && !state.unreportedChange,
780
+ // one this field now covers. Sound because every byte change carries its
781
+ // own finding (see {@link mutate}).
782
+ sgrNote: notes.length > 0 && warnings.length === 0,
726
783
  ...(reveal !== undefined && { reveal }),
727
784
  // Presence-gated like `reveal`: the field exists only when Layer 2 spliced
728
785
  // and at least one original survived vetting, so the common-case result
@@ -818,39 +875,19 @@ function depthMemo() {
818
875
  }
819
876
 
820
877
  /**
821
- * Sanitize every string leaf of a tool-output value, preserving its shape (a
822
- * structured tool output whose shape changes would be ignored by a harness,
823
- * leaking the raw value). Non-string leaves pass through; `warnings` and
824
- * `notes` accumulate across leaves, split by severity (see ./severity.mjs).
825
- * `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
826
- * caller can still pick the quiet banner when no leaf raised a warning.
827
- *
828
- * Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
829
- * as an unhandled async rejection (a DoS that leaves the output un-sanitized):
830
- * nesting past {@link MAX_DEPTH}, and a reference cycle. Either replaces the
831
- * offending subtree with a placeholder string + a warning, never passing the
832
- * raw subtree through. Keys are also screened for hidden chars (see below).
833
- *
834
- * `reveals` accumulates each string leaf's pre-Layer-2 text (present only when
835
- * the HTML splice removed bytes) so a caller can persist what was hidden — the
836
- * structured-output analogue of {@link sanitizeText}'s `reveal`. Same
837
- * mutated-accumulator contract as `warnings`.
838
- *
839
- * `splices` accumulates each string leaf's Layer-2 placeholder→original pairs
840
- * (each `original` already vetted, withheld entries dropped — see
841
- * {@link sanitizeText}) — the per-placeholder twin of `reveals`, so a hook
842
- * caller gets them for object-shaped tool output too. Same mutated-accumulator
843
- * contract as `reveals`.
878
+ * Sanitize every string of a tool-output value with `options` — each string
879
+ * leaf AND each object key — preserving its shape. The walk itself, and every
880
+ * rule it applies, is {@link sanitizeValueWith}'s; this binds the leaf and key
881
+ * sanitizers to {@link sanitizeText}. A key runs every layer a leaf does except
882
+ * Layer 5: a span deletion in a NAME can collapse two fields into one, which
883
+ * costs both their values (see the collision rule).
844
884
  * @param {any} value
845
885
  * @param {SanitizeTextOptions} options
846
886
  * @param {string[]} warnings
847
887
  * @param {string[]} [reveals]
848
- * @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
849
- * appended last so an existing positional caller keeps working (it simply
850
- * discards the notes, which is exactly as loud as before the split)
851
- * @param {Array<{ placeholder: string, original: string }>} [splices] appended
852
- * after `notes` for the same positional-compatibility reason
853
- * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
888
+ * @param {string[]} [notes] the NOTE-severity counterpart of `warnings`
889
+ * @param {Array<{ placeholder: string, original: string }>} [splices]
890
+ * @returns {Promise<WalkResult>}
854
891
  */
855
892
  export async function sanitizeValue(
856
893
  value,
@@ -860,70 +897,121 @@ export async function sanitizeValue(
860
897
  notes = [],
861
898
  splices = [],
862
899
  ) {
863
- return sanitizeValueAt(
900
+ const keyOptions = { ...options, filterInjection: undefined };
901
+ return sanitizeValueWith(
864
902
  value,
865
- options,
866
- warnings,
867
- notes,
868
- reveals,
869
- splices,
903
+ {
904
+ leaf: (text) => sanitizeText(text, options),
905
+ key: (text) => sanitizeText(text, keyOptions),
906
+ },
907
+ { warnings, notes, reveals, splices },
908
+ );
909
+ }
910
+
911
+ /**
912
+ * THE structured-output walk, shared by {@link sanitizeValue} and the output
913
+ * hook so the two cannot disagree about a value's strings. Runs `leaf` on every
914
+ * string leaf and `key` on every object key (a field name is as
915
+ * attacker-controlled as a leaf) and rebuilds the same shape (a harness ignores
916
+ * a reshaped output and shows the RAW one). Each string's findings, `reveal` and
917
+ * `splices` fold into `sinks`, whose caller owns dedup.
918
+ *
919
+ * Keys that sanitize to the same name fail closed: every value under that name
920
+ * becomes {@link COLLISION_WITHHELD_MESSAGE}, the later ones under a
921
+ * disambiguated key so the field count holds. Nesting past {@link MAX_DEPTH}
922
+ * and a reference cycle become a placeholder plus a warning, not a `RangeError`.
923
+ * `sgrNote` holds only when the walk added no warning.
924
+ * @param {any} value
925
+ * @param {StringSanitizers} sanitizers
926
+ * @param {WalkSinks} sinks
927
+ * @returns {Promise<WalkResult>}
928
+ */
929
+ export async function sanitizeValueWith(value, sanitizers, sinks) {
930
+ const warnedBefore = sinks.warnings.length;
931
+ const result = await sanitizeValueAt(
932
+ value,
933
+ {
934
+ ...sinks,
935
+ ...sanitizers,
936
+ seen: new WeakSet(),
937
+ memo: depthMemo(),
938
+ },
870
939
  0,
871
- new WeakSet(),
872
- depthMemo(),
940
+ "",
941
+ );
942
+ return {
943
+ ...result,
944
+ sgrNote: result.sgrNote && sinks.warnings.length === warnedBefore,
945
+ };
946
+ }
947
+
948
+ /** A colliding field's WHOLE value, so no scalar passes under a legitimate name (the JSON type change is accepted). */
949
+ export const COLLISION_WITHHELD_MESSAGE =
950
+ "[WITHHELD — this field's name collided with another after sanitization]";
951
+
952
+ /**
953
+ * The warning for a post-sanitization key collision, naming where it sits.
954
+ * Exported so tests assert by reference rather than re-typing the prose.
955
+ * @param {string} name the collapsed-to name
956
+ * @param {string} path dotted location of the owning object ("" = top level)
957
+ * @returns {string}
958
+ */
959
+ export function collisionWarning(name, path) {
960
+ return (
961
+ `two or more fields in the tool output collapsed to the name "${name}" after ` +
962
+ `sanitization (at ${path === "" ? "the top level" : path}); their values were ` +
963
+ `WITHHELD (fail closed) because there is no way to tell which field was ` +
964
+ `legitimate. Sibling fields are unaffected — do not treat the withheld values ` +
965
+ `as empty or absent; re-request them by a means that does not depend on this ` +
966
+ `field name, or ask the user`
873
967
  );
874
968
  }
875
969
 
876
970
  /**
877
- * Recursion core for {@link sanitizeValue}, carrying the current `depth` and the
878
- * `seen` set of ancestor containers on the active path (a WeakSet, so a value
879
- * reused across sibling branches — legitimate sharing, not a cycle — is not
880
- * mistaken for a back-edge; only a true ancestor still on the stack triggers
881
- * the cycle guard, and it is removed on the way back up).
971
+ * @typedef {{ value: any, modified: boolean, sgrNote: boolean }} WalkResult
972
+ * @typedef {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} StringVerdict
973
+ * @typedef {{ leaf: (text: string) => Promise<StringVerdict>, key: (text: string) => Promise<StringVerdict> }} StringSanitizers
974
+ * @typedef {{ warnings: string[], notes: string[], reveals: string[], splices: Array<{ placeholder: string, original: string }> }} WalkSinks
975
+ * @typedef {WalkSinks & StringSanitizers & { seen: WeakSet<object>, memo: ReturnType<typeof depthMemo<WalkResult>> }} WalkContext
976
+ * `seen` holds the ancestors on the active path only (a value shared across
977
+ * sibling branches is not a cycle). `memo` caches each FULLY-PROCESSED result
978
+ * by `(node, depth)` (see {@link depthMemo}); without it a shared-substructure
979
+ * DAG is re-sanitized once per PATH — a ~25-object diamond measured 68 s.
980
+ * Skipping a cached node's duplicate findings is harmless: callers dedup.
981
+ */
982
+
983
+ /**
984
+ * Run one string (a leaf or a key) through `sanitize`, folding it into the sinks.
985
+ * @param {string} text
986
+ * @param {(text: string) => Promise<StringVerdict>} sanitize
987
+ * @param {WalkContext} ctx
988
+ * @returns {Promise<WalkResult>}
989
+ */
990
+ async function sanitizeString(text, sanitize, ctx) {
991
+ const result = await sanitize(text);
992
+ ctx.warnings.push(...result.warnings);
993
+ ctx.notes.push(...result.notes);
994
+ if (result.reveal !== undefined) ctx.reveals.push(result.reveal);
995
+ if (result.splices !== undefined) ctx.splices.push(...result.splices);
996
+ return {
997
+ value: result.cleaned,
998
+ modified: result.modified,
999
+ sgrNote: result.sgrNote,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Recursion core for {@link sanitizeValueWith}.
882
1005
  * @param {any} value
883
- * @param {SanitizeTextOptions} options
884
- * @param {string[]} warnings
885
- * @param {string[]} notes accumulates each string leaf's NOTE-severity findings
886
- * @param {string[]} reveals accumulates each string leaf's pre-Layer-2 text
887
- * @param {Array<{ placeholder: string, original: string }>} splices accumulates
888
- * each string leaf's Layer-2 placeholder→original pairs (already vetted)
1006
+ * @param {WalkContext} ctx
889
1007
  * @param {number} depth
890
- * @param {WeakSet<object>} seen
891
- * @param {ReturnType<typeof depthMemo<{ value: any, modified: boolean, sgrNote: boolean }>>} memo
892
- * Cache of the FULLY-PROCESSED result, keyed by `(node, depth)` — see
893
- * {@link depthMemo} for why the depth belongs in the key. Without a memo at
894
- * all, a shared-substructure DAG (one node reached by many parents) is
895
- * re-sanitized once per PATH — exponential in the number of shared nodes (a
896
- * ~25-object diamond measured at 68 s, far under MAX_DEPTH) — since the path-
897
- * scoped `seen` set only guards cycles, not repeated work. Because warnings
898
- * dedup in composeContext, skipping a cached node's duplicate warnings is
899
- * harmless. A cached node's `reveals` and `splices` are likewise not
900
- * re-emitted, harmless for the same reason (the caller dedups reveals by
901
- * content and splices by their content-addressed key).
902
- * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
1008
+ * @param {string} path dotted location of `value`, named only in a key
1009
+ * collision's warning
1010
+ * @returns {Promise<WalkResult>}
903
1011
  */
904
- async function sanitizeValueAt(
905
- value,
906
- options,
907
- warnings,
908
- notes,
909
- reveals,
910
- splices,
911
- depth,
912
- seen,
913
- memo,
914
- ) {
915
- if (typeof value === "string") {
916
- const result = await sanitizeText(value, options);
917
- warnings.push(...result.warnings);
918
- notes.push(...result.notes);
919
- if (result.reveal !== undefined) reveals.push(result.reveal);
920
- if (result.splices !== undefined) splices.push(...result.splices);
921
- return {
922
- value: result.cleaned,
923
- modified: result.modified,
924
- sgrNote: result.sgrNote,
925
- };
926
- }
1012
+ async function sanitizeValueAt(value, ctx, depth, path) {
1013
+ if (typeof value === "string") return sanitizeString(value, ctx.leaf, ctx);
1014
+ const { warnings, seen, memo } = ctx;
927
1015
  // Memo hit: a shared node already fully sanitized AT THIS DEPTH on another
928
1016
  // path. Returning the cached result (same reference) collapses the DAG to
929
1017
  // linear work and preserves shape; it never short-circuits the cycle guard,
@@ -1000,17 +1088,12 @@ async function sanitizeValueAt(
1000
1088
  const out = [];
1001
1089
  let modified = false;
1002
1090
  let sgrNote = false;
1003
- for (const item of value) {
1091
+ for (const [index, item] of value.entries()) {
1004
1092
  const result = await sanitizeValueAt(
1005
1093
  item,
1006
- options,
1007
- warnings,
1008
- notes,
1009
- reveals,
1010
- splices,
1094
+ ctx,
1011
1095
  depth + 1,
1012
- seen,
1013
- memo,
1096
+ `${path}[${index}]`,
1014
1097
  );
1015
1098
  out.push(result.value);
1016
1099
  if (result.modified) modified = true;
@@ -1020,50 +1103,7 @@ async function sanitizeValueAt(
1020
1103
  memo.set(value, depth, arrResult);
1021
1104
  return arrResult;
1022
1105
  }
1023
- /** @type {Record<string, any>} */
1024
- const out = {};
1025
- let modified = false;
1026
- let sgrNote = false;
1027
- for (const [key, item] of Object.entries(value)) {
1028
- // Screen the KEY for hidden chars (Layer 1). We FLAG but do NOT rewrite:
1029
- // a sanitized key can collide with a sibling key (silently dropping a
1030
- // field) or break a downstream schema that matches on the exact name, so
1031
- // precision wins — we keep the original key and warn, letting an operator
1032
- // decide, rather than mangle the object's shape. (A clean key is silent.)
1033
- // A key-only finding does NOT set `modified`: `modified` means output
1034
- // BYTES changed (see composeContext's contract), and the key is left
1035
- // intact here on purpose — only the warning fires.
1036
- const { cleaned: cleanKey } = applyLayer1(key);
1037
- if (cleanKey !== key)
1038
- warnings.push(
1039
- "An object key in structured tool output carried hidden/invisible characters (key left intact, value sanitized)",
1040
- );
1041
- const result = await sanitizeValueAt(
1042
- item,
1043
- options,
1044
- warnings,
1045
- notes,
1046
- reveals,
1047
- splices,
1048
- depth + 1,
1049
- seen,
1050
- memo,
1051
- );
1052
- // Bracket assignment on a literal "__proto__" key triggers the special
1053
- // Object.prototype setter instead of creating an own property — the
1054
- // field would silently vanish from `out`'s own keys and `out`'s
1055
- // prototype would become attacker-controlled. defineProperty always
1056
- // creates a normal own data property regardless of the key's name.
1057
- Object.defineProperty(out, key, {
1058
- value: result.value,
1059
- enumerable: true,
1060
- writable: true,
1061
- configurable: true,
1062
- });
1063
- if (result.modified) modified = true;
1064
- if (result.sgrNote) sgrNote = true;
1065
- }
1066
- const objResult = { value: out, modified, sgrNote };
1106
+ const objResult = await sanitizeObject(value, ctx, depth, path);
1067
1107
  memo.set(value, depth, objResult);
1068
1108
  return objResult;
1069
1109
  } finally {
@@ -1071,24 +1111,116 @@ async function sanitizeValueAt(
1071
1111
  }
1072
1112
  }
1073
1113
 
1114
+ /**
1115
+ * A plain object: keys and values per {@link sanitizeValueWith}'s rules.
1116
+ * @param {Record<string, any>} value
1117
+ * @param {WalkContext} ctx
1118
+ * @param {number} depth
1119
+ * @param {string} path
1120
+ * @returns {Promise<WalkResult>}
1121
+ */
1122
+ async function sanitizeObject(value, ctx, depth, path) {
1123
+ /** @type {Record<string, any>} */
1124
+ const out = {};
1125
+ let modified = false;
1126
+ let sgrNote = false;
1127
+ // Every source name is sanitized and RESERVED before any is written, so a
1128
+ // generated `<name> [withheld duplicate N]` can never equal a later raw
1129
+ // field's name and falsely withhold an unrelated value.
1130
+ /** @type {Array<{ name: Awaited<ReturnType<typeof sanitizeString>>, item: unknown }>} */
1131
+ const named = [];
1132
+ /** @type {Set<string>} */
1133
+ const reserved = new Set();
1134
+ for (const [key, item] of Object.entries(value)) {
1135
+ const name = await sanitizeString(key, ctx.key, ctx);
1136
+ reserved.add(name.value);
1137
+ named.push({ name, item });
1138
+ }
1139
+ // Names already withheld, so a THIRD field colliding onto the same name does
1140
+ // not re-warn; and the next free duplicate index per name, so N collisions
1141
+ // probe in O(N) rather than O(N²) — the count is the tool output's to choose.
1142
+ /** @type {Set<string>} */
1143
+ const collided = new Set();
1144
+ /** @type {Map<string, number>} */
1145
+ const nextDuplicate = new Map();
1146
+ for (const { name, item } of named) {
1147
+ const result = await sanitizeValueAt(
1148
+ item,
1149
+ ctx,
1150
+ depth + 1,
1151
+ path === "" ? name.value : `${path}.${name.value}`,
1152
+ );
1153
+ if (name.modified || result.modified) modified = true;
1154
+ if (name.sgrNote || result.sgrNote) sgrNote = true;
1155
+ if (!Object.hasOwn(out, name.value)) {
1156
+ defineOwn(out, name.value, result.value);
1157
+ continue;
1158
+ }
1159
+ modified = true;
1160
+ if (!collided.has(name.value)) {
1161
+ collided.add(name.value);
1162
+ ctx.warnings.push(collisionWarning(name.value, path));
1163
+ defineOwn(out, name.value, COLLISION_WITHHELD_MESSAGE);
1164
+ }
1165
+ // Skip every reserved source name AND every earlier generated one.
1166
+ const slot = (/** @type {number} */ n) =>
1167
+ `${name.value} [withheld duplicate ${n}]`;
1168
+ let index = nextDuplicate.get(name.value) ?? 2;
1169
+ while (reserved.has(slot(index)) || Object.hasOwn(out, slot(index)))
1170
+ index++;
1171
+ nextDuplicate.set(name.value, index + 1);
1172
+ defineOwn(out, slot(index), COLLISION_WITHHELD_MESSAGE);
1173
+ }
1174
+ return { value: out, modified, sgrNote };
1175
+ }
1176
+
1177
+ /**
1178
+ * Write `key` as an own data property. Not `out[key] = value`: a "__proto__"
1179
+ * key assigned with `=` hits Object.prototype's setter, silently dropping the
1180
+ * field from `out`'s own keys and making `out`'s prototype attacker-controlled.
1181
+ * @param {Record<string, any>} out
1182
+ * @param {string} key
1183
+ * @param {unknown} value
1184
+ * @returns {void}
1185
+ */
1186
+ function defineOwn(out, key, value) {
1187
+ Object.defineProperty(out, key, {
1188
+ value,
1189
+ enumerable: true,
1190
+ writable: true,
1191
+ configurable: true,
1192
+ });
1193
+ }
1194
+
1074
1195
  /**
1075
1196
  * Compose the model-facing context line for a sanitized/flagged tool output.
1076
1197
  * `injectionAlert` is the caller's optional trailing alert (e.g. appended only
1077
1198
  * for untrusted-ingress tools where a semantic-injection filter actually ran).
1199
+ *
1200
+ * `notes` follow the WARNING text: a note can carry the instruction a
1201
+ * detect-only finding rests on ("do not fetch them"), which an unrelated
1202
+ * warning does not make redundant. {@link INERT_ANSI_NOTE} alone is dropped
1203
+ * beside a warning (matched by the constant, not its wording).
1078
1204
  * @param {boolean} modified output bytes were changed (vs. flagged only)
1079
1205
  * @param {string[]} warnings
1080
- * @param {{ injectionAlert?: string }} [options]
1206
+ * @param {{ injectionAlert?: string, notes?: string[] }} [options]
1081
1207
  * @returns {string}
1082
1208
  */
1083
1209
  export function composeContext(
1084
1210
  modified,
1085
1211
  warnings,
1086
- { injectionAlert = "" } = {},
1212
+ { injectionAlert = "", notes = [] } = {},
1087
1213
  ) {
1088
1214
  const prefix = modified
1089
1215
  ? "WARNING: Tool output sanitized. "
1090
1216
  : "WARNING: Tool output flagged (content not modified). ";
1091
- return prefix + [...new Set(warnings)].join(". ") + "." + injectionAlert;
1217
+ const kept = [...new Set(notes)].filter(
1218
+ (text) => warnings.length === 0 || text !== INERT_ANSI_NOTE,
1219
+ );
1220
+ return [
1221
+ prefix + [...new Set(warnings)].join(". ") + "." + injectionAlert,
1222
+ ...kept,
1223
+ ].join(" ");
1092
1224
  }
1093
1225
 
1094
1226
  /**
@@ -1288,15 +1420,7 @@ function suppressAt(value, message, depth, seen, memo) {
1288
1420
  /** @type {Record<string, any>} */
1289
1421
  const out = {};
1290
1422
  for (const [key, item] of Object.entries(value))
1291
- // See sanitizeValueAt's identical guard: bracket assignment on a literal
1292
- // "__proto__" key hits the special setter instead of creating an own
1293
- // property, silently dropping the field and mutating out's prototype.
1294
- Object.defineProperty(out, key, {
1295
- value: suppressAt(item, message, depth + 1, seen, memo),
1296
- enumerable: true,
1297
- writable: true,
1298
- configurable: true,
1299
- });
1423
+ defineOwn(out, key, suppressAt(item, message, depth + 1, seen, memo));
1300
1424
  memo.set(value, depth, out);
1301
1425
  return out;
1302
1426
  } finally {