agent-sanitizer 2.24.2 → 2.26.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/output.mjs CHANGED
@@ -24,10 +24,15 @@
24
24
  * actually removes something, so a secret that a deletion reconstitutes is
25
25
  * still caught before this function returns.
26
26
  */
27
- import { CATEGORY, describeStripped } from "./invisible.mjs";
27
+ import {
28
+ CATEGORY,
29
+ describeStripped,
30
+ isIncidentalInvisible,
31
+ } from "./invisible.mjs";
28
32
  import { needsMarkdownPipeline } from "./gates.mjs";
29
33
  import {
30
34
  applyLayer1,
35
+ INERT_ANSI_NOTE,
31
36
  isBenignAnsiKinds,
32
37
  LONE_SURROGATE_RE,
33
38
  } from "./layer1.mjs";
@@ -37,6 +42,13 @@ import {
37
42
  describeWarned,
38
43
  LONE_SURROGATE_WARNING,
39
44
  } from "./warnings.mjs";
45
+ import {
46
+ finding,
47
+ note,
48
+ noteMessages,
49
+ warning,
50
+ warningMessages,
51
+ } from "./severity.mjs";
40
52
  import { orderedMatches, spliceOrdered } from "./view-map.mjs";
41
53
 
42
54
  /**
@@ -145,11 +157,13 @@ function normalizeLoneSurrogates(text) {
145
157
  }
146
158
 
147
159
  /**
148
- * @typedef {{ text: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean }} PipelineState
160
+ * @typedef {{ text: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean, unreportedChange: boolean }} PipelineState
149
161
  * The running state of one {@link sanitizeText} call. Layers read `text` and
150
- * mutate it ONLY through {@link applyMutation}. `found` is the machine-readable
151
- * twin of `warnings`: the {@link CATEGORY} codes for whatever Layers 1-3
152
- * neutralized or flagged.
162
+ * mutate it ONLY through {@link applyMutation}. Findings carry their own
163
+ * severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
164
+ * single exit, so no layer can push into the wrong list. `found` is the
165
+ * machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
166
+ * for whatever Layers 1-3 neutralized or flagged.
153
167
  */
154
168
 
155
169
  /**
@@ -166,10 +180,12 @@ function normalizeLoneSurrogates(text) {
166
180
  * configured) makes an invariant of Layer 1 conditional on an unrelated
167
181
  * option.
168
182
  * 2. `modified` is set — the caller's "bytes changed" banner.
169
- * 3. `sgrNote` is cleared. It claims a display-only ANSI-color strip was the
170
- * SOLE change, which any later mutation falsifies; a caller that downgrades
171
- * the banner on `sgrNote` would otherwise suppress a redaction or splice
172
- * warning.
183
+ * 3. `unreportedChange` is set. A mutation that pushed no finding a Layer-5
184
+ * span deletion whose filter returned no warning code — is a change the
185
+ * caller was never told about, and the returned `sgrNote` ("nothing here
186
+ * rose above a note") must not invite a quiet banner over one. A mutation
187
+ * that DID report itself is covered by its own WARNING, so this flag only
188
+ * ever costs a note that would have been misleading.
173
189
  *
174
190
  * Callers decide WHETHER a mutation happened (each layer already knows: a
175
191
  * changed splice output, a redactor finding, a removed span) and call this with
@@ -186,7 +202,7 @@ function normalizeLoneSurrogates(text) {
186
202
  function applyMutation(state, nextText) {
187
203
  state.text = normalizeLoneSurrogates(nextText);
188
204
  state.modified = true;
189
- state.sgrNote = false;
205
+ state.unreportedChange = true;
190
206
  }
191
207
 
192
208
  /**
@@ -224,8 +240,12 @@ async function runRedact(state, redact) {
224
240
  }
225
241
  if (!secrets) return;
226
242
  applyMutation(state, secrets.text);
227
- state.warnings.push(
228
- `API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
243
+ // Always a WARNING: a secret reached this output, and the caller-supplied
244
+ // `note` (which credential, from where) is the part an operator acts on.
245
+ state.findings.push(
246
+ warning(
247
+ `API keys/secrets redacted: ${secrets.found.join(", ")}${secrets.note ?? ""}${REDACTION_DOCTRINE}`,
248
+ ),
229
249
  );
230
250
  }
231
251
 
@@ -246,7 +266,7 @@ export const REDACTION_DOCTRINE =
246
266
  // (./index.mjs), which runs the same layers; re-exported here because both were
247
267
  // part of this module's public surface before they moved.
248
268
  export { needsMarkdownPipeline };
249
- export { describeRemoved, describeWarned } from "./warnings.mjs";
269
+ export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
250
270
 
251
271
  /**
252
272
  * Delete each verbatim span in `spans` from `text`. The secure Layer-5
@@ -285,23 +305,54 @@ export function deleteVerbatimSpans(text, spans) {
285
305
  return { text: spliced.text, removed: spliced.spans.length };
286
306
  }
287
307
 
308
+ /**
309
+ * The tier for what Layer 1 removed.
310
+ *
311
+ * INCIDENTAL means both axes are: the ANSI was display-only (or a stray escape
312
+ * that opened nothing) AND there were too few invisible characters to spell
313
+ * anything. Either axis alone keeps the WARNING — a cursor move beside one soft
314
+ * hyphen is still a cursor move, and ten tag characters beside a colour code are
315
+ * still ten ASCII letters.
316
+ *
317
+ * The whole downgrade is gated on `sgrCarveOut`, the caller asserting local,
318
+ * first-party output. Without it — a fetched page, an MCP connector — a single
319
+ * hidden character is not incidental at all; that is the channel where one gets
320
+ * PUT there.
321
+ *
322
+ * When the strip was inert AND nothing but ANSI was found, the note says so in
323
+ * its own words ({@link INERT_ANSI_NOTE}) rather than reciting a stripped
324
+ * category, because "we removed some colour codes" is the whole finding.
325
+ * @param {string[]} invisFound
326
+ * @param {string[]} ansiKinds
327
+ * @param {string} deAnsi
328
+ * @param {boolean} sgrCarveOut
329
+ * @returns {import("./severity.mjs").Finding}
330
+ */
331
+ function layer1Finding(invisFound, ansiKinds, deAnsi, sgrCarveOut) {
332
+ const incidental =
333
+ sgrCarveOut &&
334
+ isBenignAnsiKinds(ansiKinds) &&
335
+ isIncidentalInvisible(deAnsi);
336
+ const ansiOnly = invisFound.length === 1 && invisFound[0] === CATEGORY.ANSI;
337
+ if (incidental && ansiOnly) return note(INERT_ANSI_NOTE);
338
+ return finding(!incidental, describeStripped(invisFound, deAnsi));
339
+ }
340
+
288
341
  /**
289
342
  * Layer 1 + surrogate normalisation: invisible chars, ANSI, lone surrogates.
290
- * `sgrNote` is true when the ONLY change was INERT ANSI — display-only SGR
291
- * colour and/or a stray orphan introducer that formed no sequence AND the
292
- * caller opted into the carve-out (`sgrCarveOut`); the caller reports that with
293
- * a terse note, not the WARNING prefix.
343
+ * Findings carry their own tier (see {@link layer1Finding}); a lone-surrogate
344
+ * normalisation is always loud, since splitting a secret across one is how a
345
+ * redactor is evaded.
294
346
  * @param {string} text
295
347
  * @param {boolean} sgrCarveOut
296
- * @returns {{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean }}
348
+ * @returns {{ cleaned: string, found: string[], findings: import("./severity.mjs").Finding[], modified: boolean }}
297
349
  */
298
350
  function processLayer1(text, sgrCarveOut) {
299
- /** @type {string[]} */
300
- const warnings = [];
351
+ /** @type {import("./severity.mjs").Finding[]} */
352
+ const findings = [];
301
353
  /** @type {string[]} */
302
354
  const found = [];
303
355
  let modified = false;
304
- let sgrNote = false;
305
356
  const {
306
357
  cleaned: layer1,
307
358
  deAnsi,
@@ -312,16 +363,7 @@ function processLayer1(text, sgrCarveOut) {
312
363
  if (invisFound.length > 0) {
313
364
  found.push(...invisFound);
314
365
  modified = true;
315
- // Inert ANSI with the carve-out enabled: the strip removed cosmetic styling
316
- // and/or a stray escape byte, and nothing else (found is exactly [ANSI], so
317
- // zero invisible chars were present). Report it as a note — a cursor-move,
318
- // erase or OSC token lands in ansiKinds as CSI/OSC and keeps the WARNING.
319
- sgrNote =
320
- invisFound.length === 1 &&
321
- invisFound[0] === CATEGORY.ANSI &&
322
- isBenignAnsiKinds(ansiKinds) &&
323
- sgrCarveOut;
324
- if (!sgrNote) warnings.push(describeStripped(invisFound, deAnsi));
366
+ findings.push(layer1Finding(invisFound, ansiKinds, deAnsi, sgrCarveOut));
325
367
  }
326
368
  // Normalize lone UTF-16 surrogates for ALL output: a secret split by an
327
369
  // interposed lone surrogate reads as adjacent to a model rendering its own
@@ -332,11 +374,10 @@ function processLayer1(text, sgrCarveOut) {
332
374
  if (wellFormed !== cleaned) {
333
375
  cleaned = wellFormed;
334
376
  modified = true;
335
- sgrNote = false;
336
377
  found.push(CATEGORY.LONE_SURROGATES);
337
- warnings.push(LONE_SURROGATE_WARNING);
378
+ findings.push(warning(LONE_SURROGATE_WARNING));
338
379
  }
339
- return { cleaned, found, warnings, modified, sgrNote };
380
+ return { cleaned, found, findings, modified };
340
381
  }
341
382
 
342
383
  /**
@@ -385,10 +426,18 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
385
426
  if (layer2.removed.comments > 0)
386
427
  state.found.push(CATEGORY.HTML_COMMENTS);
387
428
  if (layer2.removed.hidden > 0) state.found.push(CATEGORY.HIDDEN_HTML);
388
- state.warnings.push(describeHtmlSanitized(layer2.removed));
429
+ // A WARNING: these bytes were invisible to a human reading the rendered
430
+ // page and are now gone from the model's view too — the exact shape of
431
+ // a hidden-instruction payload, and the model cannot check what it was
432
+ // without the reveal sidecar.
433
+ state.findings.push(warning(describeHtmlSanitized(layer2.removed)));
389
434
  }
435
+ // A NOTE: nothing was removed and nothing was hidden. This line says "the
436
+ // page had scripts, treat their contents as data", which is true of nearly
437
+ // every page fetched — at WARNING volume it trained the reader to skip the
438
+ // banner Layer 2's actual splice needs.
390
439
  const preserved = describeWarned(layer2.warned);
391
- if (preserved) state.warnings.push(preserved);
440
+ if (preserved) state.findings.push(note(preserved));
392
441
  }
393
442
  }
394
443
  // Layer 3 — detection only: the URLs stay intact, the model is told not to
@@ -397,9 +446,21 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
397
446
  // suspicious, not less, yet Layer 2 has already removed it from `cleaned`.
398
447
  if (exfilScan) {
399
448
  const threats = detectExfil(inputText);
449
+ // Severity tracks who does the fetching. An auto-fetched target — an image,
450
+ // a stylesheet, a form action, a meta refresh — exfiltrates the moment the
451
+ // content renders, with nobody deciding anything: a WARNING. A plain LINK
452
+ // cannot exfiltrate unless the model chooses to follow it, and the sentence
453
+ // it is reported in is precisely the instruction not to, so it is a NOTE.
454
+ // One auto-fetched threat raises the whole finding — the loudest member
455
+ // wins, since they share one line.
400
456
  if (threats) {
401
457
  state.found.push(CATEGORY.EXFIL_URLS);
402
- state.warnings.push(describeExfil(threats));
458
+ state.findings.push(
459
+ finding(
460
+ threats.some((threat) => threat.autoFetched),
461
+ describeExfil(threats),
462
+ ),
463
+ );
403
464
  }
404
465
  }
405
466
  return reveal;
@@ -432,17 +493,22 @@ async function applyMarkdownPipeline(state, { html, exfilScan }) {
432
493
  * no-finding paths are already well-formed.
433
494
  * @param {string} text
434
495
  * @param {SanitizeTextOptions["redact"]} redact
435
- * @param {string[]} warnings
496
+ * @param {import("./severity.mjs").Finding[]} findings
436
497
  * @param {string} label
437
498
  * @returns {Promise<string | undefined>} vetted text, or undefined if withheld
438
499
  */
439
- async function vetStageValue(text, redact, warnings, label) {
500
+ async function vetStageValue(text, redact, findings, label) {
440
501
  if (!redact) return text;
441
502
  try {
442
503
  const secrets = await redact(text);
443
504
  return secrets ? normalizeLoneSurrogates(secrets.text) : text;
444
505
  } catch {
445
- warnings.push(`Withheld the ${label}: it could not be vetted for secrets`);
506
+ // A WARNING: the caller asked for this field and is not getting it, and the
507
+ // reason is an unrunnable redactor — the same fault that fails `cleaned`
508
+ // closed, just with a survivable remedy here.
509
+ findings.push(
510
+ warning(`Withheld the ${label}: it could not be vetted for secrets`),
511
+ );
446
512
  return undefined;
447
513
  }
448
514
  }
@@ -474,22 +540,35 @@ async function vetStageValue(text, redact, warnings, label) {
474
540
  * post-mutation invariants and forget the rest, and every string in the returned
475
541
  * object has traversed Layer 4.
476
542
  *
477
- * `found` is the machine-readable twin of `warnings` — the {@link CATEGORY}
478
- * codes for what Layers 1-3 neutralized or flagged, in the order the layers ran.
479
- * Layers 4 and 5 have no category codes (their findings are the injected seam's
480
- * own vocabulary), so they contribute warnings only.
543
+ * Findings come back SPLIT BY SEVERITY (see ./severity.mjs): `warnings` holds
544
+ * everything injection-shaped the banner a caller must show and `notes`
545
+ * holds what happened but is not alarming. `warnings` therefore keeps exactly
546
+ * the meaning it always had, and a caller that ignores `notes` is no louder
547
+ * than before, just quieter about incidental bytes.
548
+ *
549
+ * `found` is the machine-readable twin, and the severity split does NOT reach
550
+ * it — the {@link CATEGORY} codes for what Layers 1-3 neutralized or flagged,
551
+ * in the order the layers ran, whichever tier described them. Layers 4 and 5
552
+ * have no category codes (their findings are the injected seam's own
553
+ * vocabulary), so they contribute findings only.
481
554
  * @param {string} text
482
555
  * @param {SanitizeTextOptions} [options]
483
- * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
556
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
484
557
  */
485
558
  export async function sanitizeText(text, options = {}) {
486
559
  const { redact, filterInjection, sgrCarveOut = false } = options;
487
- const { warnings, found, cleaned, modified, sgrNote } = processLayer1(
560
+ const { findings, found, cleaned, modified } = processLayer1(
488
561
  text,
489
562
  sgrCarveOut,
490
563
  );
491
564
  /** @type {PipelineState} */
492
- const state = { text: cleaned, found, warnings, modified, sgrNote };
565
+ const state = {
566
+ text: cleaned,
567
+ found,
568
+ findings,
569
+ modified,
570
+ unreportedChange: false,
571
+ };
493
572
 
494
573
  const revealText = await applyMarkdownPipeline(state, options);
495
574
 
@@ -520,7 +599,7 @@ export async function sanitizeText(text, options = {}) {
520
599
  // message; free text is refused (throws) so no filter-supplied byte ever
521
600
  // reaches the model-facing context. `null`/`undefined` means no warning.
522
601
  if (res.warning != null)
523
- state.warnings.push(mapFilterWarning(res.warning));
602
+ state.findings.push(warning(mapFilterWarning(res.warning)));
524
603
  }
525
604
  }
526
605
 
@@ -535,15 +614,24 @@ export async function sanitizeText(text, options = {}) {
535
614
  : await vetStageValue(
536
615
  revealText,
537
616
  redact,
538
- state.warnings,
617
+ state.findings,
539
618
  "pre-splice copy of the removed HTML",
540
619
  );
620
+ const warnings = warningMessages(state.findings);
621
+ const notes = noteMessages(state.findings);
541
622
  return {
542
623
  cleaned: state.text,
543
624
  found: state.found,
544
- warnings: state.warnings,
625
+ warnings,
626
+ notes,
545
627
  modified: state.modified,
546
- sgrNote: state.sgrNote,
628
+ // Kept under its original name (it is a published field, and renaming a
629
+ // published field for a wording win is a breaking change) but now derived
630
+ // rather than tracked: "nothing here rose above a note". That is a strict
631
+ // generalization of what it used to mean — the inert-ANSI strip that set it
632
+ // before is now simply the most common way to end up note-only.
633
+ sgrNote:
634
+ notes.length > 0 && warnings.length === 0 && !state.unreportedChange,
547
635
  ...(reveal !== undefined && { reveal }),
548
636
  };
549
637
  }
@@ -637,8 +725,10 @@ function depthMemo() {
637
725
  /**
638
726
  * Sanitize every string leaf of a tool-output value, preserving its shape (a
639
727
  * structured tool output whose shape changes would be ignored by a harness,
640
- * leaking the raw value). Non-string leaves pass through; `warnings`
641
- * accumulates across leaves. `sgrNote` is the OR across leaves.
728
+ * leaking the raw value). Non-string leaves pass through; `warnings` and
729
+ * `notes` accumulate across leaves, split by severity (see ./severity.mjs).
730
+ * `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
731
+ * caller can still pick the quiet banner when no leaf raised a warning.
642
732
  *
643
733
  * Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
644
734
  * as an unhandled async rejection (a DoS that leaves the output un-sanitized):
@@ -654,13 +744,23 @@ function depthMemo() {
654
744
  * @param {SanitizeTextOptions} options
655
745
  * @param {string[]} warnings
656
746
  * @param {string[]} [reveals]
747
+ * @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
748
+ * appended last so an existing positional caller keeps working (it simply
749
+ * discards the notes, which is exactly as loud as before the split)
657
750
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
658
751
  */
659
- export async function sanitizeValue(value, options, warnings, reveals = []) {
752
+ export async function sanitizeValue(
753
+ value,
754
+ options,
755
+ warnings,
756
+ reveals = [],
757
+ notes = [],
758
+ ) {
660
759
  return sanitizeValueAt(
661
760
  value,
662
761
  options,
663
762
  warnings,
763
+ notes,
664
764
  reveals,
665
765
  0,
666
766
  new WeakSet(),
@@ -677,6 +777,7 @@ export async function sanitizeValue(value, options, warnings, reveals = []) {
677
777
  * @param {any} value
678
778
  * @param {SanitizeTextOptions} options
679
779
  * @param {string[]} warnings
780
+ * @param {string[]} notes accumulates each string leaf's NOTE-severity findings
680
781
  * @param {string[]} reveals accumulates each string leaf's pre-Layer-2 text
681
782
  * @param {number} depth
682
783
  * @param {WeakSet<object>} seen
@@ -696,6 +797,7 @@ async function sanitizeValueAt(
696
797
  value,
697
798
  options,
698
799
  warnings,
800
+ notes,
699
801
  reveals,
700
802
  depth,
701
803
  seen,
@@ -704,6 +806,7 @@ async function sanitizeValueAt(
704
806
  if (typeof value === "string") {
705
807
  const result = await sanitizeText(value, options);
706
808
  warnings.push(...result.warnings);
809
+ notes.push(...result.notes);
707
810
  if (result.reveal !== undefined) reveals.push(result.reveal);
708
811
  return {
709
812
  value: result.cleaned,
@@ -792,6 +895,7 @@ async function sanitizeValueAt(
792
895
  item,
793
896
  options,
794
897
  warnings,
898
+ notes,
795
899
  reveals,
796
900
  depth + 1,
797
901
  seen,
@@ -827,6 +931,7 @@ async function sanitizeValueAt(
827
931
  item,
828
932
  options,
829
933
  warnings,
934
+ notes,
830
935
  reveals,
831
936
  depth + 1,
832
937
  seen,
package/src/prompt.mjs CHANGED
@@ -21,11 +21,10 @@ import {
21
21
  CHECKS,
22
22
  CATEGORY,
23
23
  CATEGORY_LABELS,
24
- LONG_RUN_RE,
25
24
  LONG_RUN_THRESHOLD,
26
25
  SCATTERED_THRESHOLD,
27
- countPayloadInvisible,
28
- stripInvisible,
26
+ countEffectiveInvisible,
27
+ payloadLongRunSample,
29
28
  } from "./invisible.mjs";
30
29
  import { isBenignAnsi, stripAnsiFully } from "./layer1.mjs";
31
30
  import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
@@ -95,31 +94,15 @@ export function classifyPrompt(prompt, strip = stripAnsiFully) {
95
94
  const hasAnsi = ANSI_INTRODUCER.test(prompt);
96
95
  const deAnsi = strip(prompt);
97
96
 
98
- const longRunSample = deAnsi.match(LONG_RUN_RE)?.[0] ?? null;
99
- // Count only PAYLOAD invisibles for the scatter gate: ZWNJ/ZWJ (and emoji
100
- // VS16) that do real rendering work are excluded, so a legitimately
101
- // joiner-dense multilingual prompt (formal Persian, an emoji ZWJ sequence) is
102
- // not blocked by sheer joiner count. This mirrors carveStrip's own
103
- // payloadInvis < SCATTERED_THRESHOLD gate so the block and strip layers agree.
104
- const payloadInvisible = countPayloadInvisible(deAnsi);
105
- // Preserved-joiner covert channel (O3). countPayloadInvisible EXCLUDES the
106
- // ZWNJ/ZWJ (and emoji selectors) that do real rendering work, so a channel
107
- // built entirely from MEANINGFUL joiners — an attacker alternates
108
- // `letter joiner letter joiner …` so every joiner sits between two cursive
109
- // letters — counts as ZERO here and would pass, even though the strip layer
110
- // (carveStrip) only PRESERVES joiners up to a per-document budget
111
- // (TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP) and strips the
112
- // surplus as payload. A prompt channel cannot strip, only block, so mirror
113
- // that budget by counting the joiners the strip layer WOULD remove — delegated
114
- // to stripInvisible (the SSOT) rather than re-deriving the budget here, which
115
- // would risk drift — and fold that surplus into the count the scatter gate
116
- // sees. A leading BOM is preserved by the strip but counted by
117
- // countPayloadInvisible, so the difference can go slightly negative; clamp it.
118
- const surplusPreservedJoiners = Math.max(
119
- 0,
120
- [...deAnsi].length - [...stripInvisible(deAnsi)].length - payloadInvisible,
121
- );
122
- const invisibleCount = payloadInvisible + surplusPreservedJoiners;
97
+ // Both gates read the SHARED definitions in ./invisible.mjs rather than
98
+ // spelling their own: what counts as a hidden run, and how many invisibles a
99
+ // text really carries once the carve-out's preserved joiners and its
100
+ // over-budget surplus are accounted for. See payloadLongRunSample /
101
+ // countEffectiveInvisible including why the run probe masks the invisibles
102
+ // the strip layer would PRESERVE, so a legitimate emoji sequence cannot block
103
+ // a prompt that the strip layer would not even have flagged.
104
+ const longRunSample = payloadLongRunSample(deAnsi);
105
+ const invisibleCount = countEffectiveInvisible(deAnsi);
123
106
  const invisiblesBelowThreshold =
124
107
  longRunSample === null && invisibleCount < SCATTERED_THRESHOLD;
125
108
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The one place a finding's LOUDNESS is decided, and the vocabulary every layer
3
+ * reports in.
4
+ *
5
+ * Every layer of this pipeline used to have exactly one volume. A cursor-spoofing
6
+ * ANSI payload and a single stray escape byte in a README produced the same
7
+ * `WARNING: Tool output sanitized` banner; so did one soft hyphen, and so did
8
+ * the `<script>` tag that every fetched web page carries. That is the failure
9
+ * mode a detector dies of: a banner that fires on every ordinary page teaches its
10
+ * reader to skip the banner, and then the one that mattered scrolls past too.
11
+ *
12
+ * So a finding carries a SEVERITY, and the two tiers mean specific things:
13
+ *
14
+ * WARNING — this text is injection-shaped. Something was hidden from a human
15
+ * reader, something was removed that a payload would have used, or a
16
+ * secret was redacted. Worth interrupting the reader for.
17
+ * NOTE — this happened, and here is how to look at it, but nothing about it
18
+ * is attack-shaped. Incidental bytes, or content that was PRESERVED
19
+ * and merely described.
20
+ *
21
+ * The tier never changes what the pipeline DOES: the same bytes are stripped,
22
+ * spliced and redacted either way, and a note is still reported. All that rides
23
+ * on it is which banner the operator sees, which is why a note is the right
24
+ * answer whenever the evidence is thin — an under-loud true finding is still
25
+ * delivered, while an over-loud false one costs the channel its credibility.
26
+ *
27
+ * Mechanism only, deliberately: WHICH findings qualify is each layer's own
28
+ * judgement, made where that layer's evidence lives (see isBenignAnsiKinds in
29
+ * ./layer1.mjs, isIncidentalInvisible in ./invisible.mjs, and the exfil/HTML
30
+ * tiers in ./output.mjs and ./index.mjs). This module owns the enum, the constructors and the
31
+ * queries so nobody spells `severity === "warning"` by hand.
32
+ */
33
+
34
+ /**
35
+ * The closed severity vocabulary. Stable, machine-readable values: branch on
36
+ * these, not on the prose.
37
+ */
38
+ export const SEVERITY = Object.freeze({
39
+ NOTE: "note",
40
+ WARNING: "warning",
41
+ });
42
+
43
+ /**
44
+ * @typedef {{ severity: string, message: string }} Finding
45
+ * A single reportable outcome and how loudly to report it.
46
+ */
47
+
48
+ /**
49
+ * A WARNING-severity finding: injection-shaped, worth the banner.
50
+ * @param {string} message
51
+ * @returns {Finding}
52
+ */
53
+ export function warning(message) {
54
+ return { severity: SEVERITY.WARNING, message };
55
+ }
56
+
57
+ /**
58
+ * A NOTE-severity finding: reported, not alarming.
59
+ * @param {string} message
60
+ * @returns {Finding}
61
+ */
62
+ export function note(message) {
63
+ return { severity: SEVERITY.NOTE, message };
64
+ }
65
+
66
+ /**
67
+ * A finding at `severity` — the constructor for a caller that has already
68
+ * computed the tier as a boolean and would otherwise write the ternary itself.
69
+ * @param {boolean} isWarning
70
+ * @param {string} message
71
+ * @returns {Finding}
72
+ */
73
+ export function finding(isWarning, message) {
74
+ return isWarning ? warning(message) : note(message);
75
+ }
76
+
77
+ /**
78
+ * The messages of every WARNING-severity finding, in order.
79
+ * @param {readonly Finding[]} findings
80
+ * @returns {string[]}
81
+ */
82
+ export function warningMessages(findings) {
83
+ return findings
84
+ .filter((entry) => entry.severity === SEVERITY.WARNING)
85
+ .map((entry) => entry.message);
86
+ }
87
+
88
+ /**
89
+ * The messages of every NOTE-severity finding, in order.
90
+ * @param {readonly Finding[]} findings
91
+ * @returns {string[]}
92
+ */
93
+ export function noteMessages(findings) {
94
+ return findings
95
+ .filter((entry) => entry.severity === SEVERITY.NOTE)
96
+ .map((entry) => entry.message);
97
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one directory no instruction-file walk ever descends into. Its own
3
+ * function so the name is spelled once, and so the two predicates that need it
4
+ * (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
5
+ * @param {string} entry a bare entry name or a path relative to the scan root
6
+ * @returns {boolean}
7
+ */
8
+ export function excludeNodeModules(entry: string): boolean;
9
+ /**
10
+ * Entries a context scan must not descend into or return: `node_modules`, and
11
+ * every child of a `.claude` directory that is not whitelisted context.
12
+ *
13
+ * The globs alone would already refuse to MATCH those files, but a glob walker
14
+ * calls this on directories as it walks and prunes the ones it rejects — which
15
+ * is where the cost actually is. Without the prune, a `.claude/worktrees/`
16
+ * holding a few repo checkouts is walked in full on every session start (and,
17
+ * because a doubled-star segment does cross into a dot directory when the
18
+ * pattern names one, a `.claude` NESTED inside a worktree was matched and
19
+ * scanned as if it were this session's context).
20
+ *
21
+ * A walker calls this with both bare names and root-relative paths, so it must
22
+ * answer for either; a bare name carries no `.claude` context and is judged only
23
+ * against `node_modules`.
24
+ * @param {string} entry a bare entry name or a path relative to the scan root
25
+ * @returns {boolean}
26
+ */
27
+ export function excludeFromContextScan(entry: string): boolean;
28
+ /**
29
+ * WHICH files an agent loads as model context, as data: the glob set and the
30
+ * walk-pruning predicate that together define "everything Claude Code reads as
31
+ * instructions, and nothing else".
32
+ *
33
+ * This is the SINGLE SOURCE for that scope. It used to live inside
34
+ * `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
35
+ * knew the answer and nobody else did: `src/instructions.mjs` takes
36
+ * caller-supplied globs by design (no agent's convention is baked into the
37
+ * engine), so the CLI, the Python port and every downstream fork spelled their
38
+ * own approximation of this list — and an approximation that drifts either
39
+ * scans bulk data that can never reach the model (the 30-second session start
40
+ * this whitelist exists to fix) or MISSES a context directory entirely, which
41
+ * is a silent hole in the one scan standing between a poisoned instruction file
42
+ * and a session that loads it.
43
+ *
44
+ * It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
45
+ * two reasons: `src/instructions.mjs` re-exports it as the library's public
46
+ * door, and the hook imports it RELATIVELY — deliberately not through the
47
+ * `agent-sanitizer` specifier the plugin bundle pins to a published engine.
48
+ * This scope is hook POLICY, not engine behavior: it must ship and move with the
49
+ * hook that walks it, or a plugin built against an older pin would prune the
50
+ * wrong directories while believing it had scanned everything.
51
+ */
52
+ /**
53
+ * The `.claude/` subdirectories whose markdown Claude Code loads as model
54
+ * context. This is a WHITELIST, and that is the point: `.claude/` is also where
55
+ * tooling parks bulk data that is never loaded as context — `worktrees/`
56
+ * (entire checked-out copies of the repo), plus caches, transcripts and
57
+ * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
58
+ * populated worktrees that is thousands of files READ at every session start:
59
+ * one report put it at 30 seconds of blocked startup, paid for scanning files
60
+ * that cannot reach the model.
61
+ *
62
+ * A whitelist, not a `worktrees` denylist, because the failure modes are not
63
+ * symmetric: an unlisted context directory costs a scan nobody asked for anyway
64
+ * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
65
+ * while an unlisted BULK directory silently costs every future session its
66
+ * startup. Add an entry here when Claude Code starts loading a new `.claude/`
67
+ * subdirectory as context.
68
+ */
69
+ export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
70
+ /**
71
+ * Every glob whose matches Claude Code loads as model context: the
72
+ * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
73
+ * the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
74
+ * containing directory — a load path that bypasses the PostToolUse sanitizer —
75
+ * so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
76
+ * uncleaned unless something scans it here.
77
+ *
78
+ * `**` does not descend into dot directories, so NESTED `.claude/` trees need
79
+ * their own doubled-star-prefixed patterns: without them a directory-scoped
80
+ * skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
81
+ * load path — is never matched. That same rule is why the root `.claude` needs
82
+ * no separate entry: a leading doubled star matches zero segments, so the
83
+ * nested patterns cover the root tree too.
84
+ *
85
+ * Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
86
+ * MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
87
+ */
88
+ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];