agent-sanitizer 2.60.3 → 2.61.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/THREAT-MODEL.md CHANGED
@@ -30,11 +30,22 @@ table](./README.md#entry-points) maps each to its import.
30
30
 
31
31
  - A **single leading BOM** (a legitimate marker); interior BOMs are stripped.
32
32
  - **ZWNJ (U+200C) / ZWJ (U+200D)** in genuine linguistic context: between two
33
- letters of a script whose orthography requires them (Arabic, Devanagari,
34
- Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam,
35
- Sinhala) or inside an emoji ZWJ sequence. The carve-out fires only when **both**
33
+ letters of a script whose orthography requires them (the Arabic-family
34
+ cursive scripts by Joining_Type, and every script that owns a Unicode
35
+ virama — Devanagari through Sinhala, plus Brahmi, Grantha, Javanese,
36
+ Balinese, Newa and the rest — after a virama on a consonant), a ZWJ between
37
+ a consonant and the virama after it (the Bengali RA with ya-phala, the
38
+ Marathi eyelash RA), or inside an emoji ZWJ sequence.
39
+ The carve-out fires only when **both**
36
40
  neighbors clearly belong to the context, and it is disabled once the total
37
41
  invisible count crosses a scatter floor—over-stripping beats under-stripping.
42
+ - **An emoji glyph's own spelling** draws on no preserve budget: the VS16 on a
43
+ text-default emoji (❤, ⚠), a ZWJ that UAX #29's GB11 keeps inside one
44
+ grapheme (🏳️‍🌈, 👨‍👩‍👧‍👦), and a registered subdivision flag's tags. A VS15, a
45
+ VS16 on an emoji-default base, and a ZWJ GB11 does not join stay metered.
46
+ Residuals: one visible bit per text-default emoji (VS16 or not), and about one
47
+ hidden bit per adjacent pair of pictographs (an unregistered ZWJ like 😀‍😀
48
+ renders as two glyphs either way). Past the scatter floor all are stripped.
38
49
  - **Blank fillers doing real work in their own script**: the Braille blank
39
50
  (U+2800) beside a real cell, a Hangul filler beside a real jamo/syllable. A
40
51
  _run_ of fillers has only fillers for neighbors, so it fails the anchor and is
@@ -64,8 +75,11 @@ does not run a fixed sequence of passes; it iterates the whole
64
75
  deletes at least one character), and only once that is stable does it sweep any
65
76
  residual raw control introducer—7-bit ESC (U+001B) or any 8-bit C1 control
66
77
  (U+0080–U+009F)—outright. Sweeping earlier would strand a hidden control as
67
- visible text; a final unconditional sweep after the loop keeps the
68
- no-raw-introducer guarantee independent of the iteration bound. The result
78
+ visible text. Input that outruns the iteration bound—escapes hidden behind
79
+ selectors the carve-out keeps until each removal shrinks its budget—falls back
80
+ to a carve-out-free strip of every invisible, every sequence and every
81
+ introducer, so neither guarantee below depends on the bound; the carve-out is
82
+ given up only on input built to outrun it. The result
69
83
  carries no raw ANSI introducer for _any_ input, and re-cleaning it reproduces
70
84
  it exactly—the idempotence the Edit-repair rehydrator's soundness gate assumes.
71
85
  One tokenizer answers every ANSI question (what to splice, and whether what was
@@ -309,15 +323,27 @@ one in a command spoofs what the user is shown as it runs.
309
323
  Display-only SGR colour is the exception, and is preserved byte-for-byte. The
310
324
  SGR grammar is closed — parameters and a final `m` — so it restyles visible text
311
325
  and can do nothing else; stripping it costs a model the colourized fixture, TUI
312
- golden file or prompt string it deliberately wrote, and buys no safety. Two
313
- shapes end the exception, both of them the model-sees/human-sees divergence
326
+ golden file or prompt string it deliberately wrote, and buys no safety. Three
327
+ shapes end the exception, all of them the model-sees/human-sees divergence
314
328
  rather than a display choice: a CONCEAL parameter (`ESC[8m`), which blanks the
315
- text that follows for a human while its bytes stay readable to a model, and a
316
- run of sequences with nothing that RENDERS between them, which puts no glyph on
317
- the screen at all and is therefore carrying data rather than colour. Either one
318
- strips the whole field.
319
-
320
- Both shapes are read as terminal semantics rather than token shapes, because a
329
+ text that follows for a human while its bytes stay readable to a model; a
330
+ foreground painted in its own background (`ESC[30;40m`) while a glyph goes by,
331
+ which hides it the same way; and a run of sequences with nothing that RENDERS
332
+ between them, which puts no glyph on the screen at all and is therefore
333
+ carrying data rather than colour. Conceal or a matching foreground still in
334
+ force when the text ENDS counts too: rendition outlives its bytes. Any one
335
+ strips the whole field, and the prompt gate blocks on the same answer. The
336
+ invisible verdict reads the de-ANSI'd view on both surfaces, since SGR bytes
337
+ render nothing and must not buy preserved joiners and selectors.
338
+
339
+ The colour match is judged only where unambiguous: a default colour matches
340
+ nothing, bold over a basic colour may render brighter, and whitespace hides
341
+ nothing. Block Elements (U+2580–U+259F) are judged across the whole text: in
342
+ matching colours each paints a solid cell, so one glyph repeated (a half-block
343
+ image's flat region) is styling, while a second distinct one is a hidden
344
+ channel.
345
+
346
+ All three are read as terminal semantics rather than token shapes, because a
321
347
  reader that matches shapes is one encoding away from the wrong answer. Conceal
322
348
  is threaded as STATE, so a reveal or reset later in the same sequence cancels it
323
349
  (`ESC[8;28;31m` renders red and visible) while an erase between two sequences
@@ -338,7 +364,10 @@ its colour.
338
364
  Both protections run to a fixed point over each field rather than once each,
339
365
  because they feed one another: removing an invisible character completes a
340
366
  sequence that was incomplete when the terminal stage ran (`ESC[`⟨ZWSP⟩`2J`), and
341
- removing a sequence makes invisibles adjacent that were not.
367
+ removing a sequence makes invisibles adjacent that were not. A field still
368
+ changing at the round bound gets the same carve-out-free strip as Layer 1's
369
+ fallback rather than being written unfinished, and a field the strip leaves
370
+ byte for byte is reported as untouched, never as a rewrite.
342
371
 
343
372
  ## Confusable folding (tool input)
344
373
 
@@ -7,9 +7,10 @@
7
7
  * TAG block used for ASCII smuggling and zero-width joiners) and variation
8
8
  * selectors can encode a hidden message that another AI reading the
9
9
  * committed file / PR / commit later decodes, while staying invisible to a
10
- * human reviewer. Stripped when payload-capable (see isPayloadCapable):
11
- * gated on volume because incidental joiners / emoji selectors are benign
12
- * and authored content is *persisted*, so over-stripping is costly.
10
+ * human reviewer. Stripped when the engine's payloadVerdict calls the field
11
+ * payload-capable: gated on volume because incidental joiners / emoji
12
+ * selectors are benign and authored content is *persisted*, so
13
+ * over-stripping is costly.
13
14
  *
14
15
  * 2. Terminal-display rewriting. ANSI/terminal control sequences (CSI/OSC)
15
16
  * authored into a command — echoed and executed live — or into file
@@ -22,8 +23,9 @@
22
23
  * carry an OSC payload, so stripping it only costs a model the colourized
23
24
  * fixture, TUI golden file or prompt string it deliberately wrote. The
24
25
  * carve-out ends where SGR stops being styling (see the engine's
25
- * sgrCarriesPayload): a CONCEAL parameter blanks text for a human while a
26
- * model still reads it, and a long run of sequences with nothing that
26
+ * sgrCarriesPayload): a CONCEAL parameter or a foreground painted in its
27
+ * own background blanks text for a human while a model still reads it,
28
+ * and a long run of sequences with nothing that
27
29
  * renders between them puts no glyph on the screen at all, which is a
28
30
  * covert channel rather than colour.
29
31
  *
@@ -57,11 +59,11 @@ import { readKnob } from "./knobs.mjs";
57
59
  // static import of this module, before its fail-closed catch runs). A failed
58
60
  // load leaves these bindings undefined, so sanitizeField's calls throw into
59
61
  // the fail-closed catch (ask) instead.
60
- const { stripAnsiFully, isBenignAnsiKinds, sgrCarriesPayload } =
62
+ const { stripAnsiFully, stripTerminalFully, escapesPreservable } =
61
63
  /** @type {typeof import("agent-sanitizer")} */ (
62
64
  await lazyImport("agent-sanitizer")
63
65
  );
64
- const { STRIP, SCATTERED_THRESHOLD, hasLongRun, stripInvisible } =
66
+ const { payloadVerdict, stripInvisibleWithReport } =
65
67
  /** @type {typeof import("agent-sanitizer/invisible")} */ (
66
68
  await lazyImport("agent-sanitizer/invisible")
67
69
  );
@@ -143,32 +145,14 @@ export function authoredScopeDecision(tool) {
143
145
  return { kind: "undeclared" };
144
146
  }
145
147
 
146
- // Payload-capable: a long contiguous run, or enough scattered invisibles to
147
- // carry a message. Mirrors sanitize-user-prompt so the model→world and
148
- // user→model surfaces share one definition of "stego payload".
149
- /** @param {string} text */
150
- function isPayloadCapable(text) {
151
- // hasLongRun, not a pattern built here: the engine's scan is bounded per
152
- // `exec`, which is what keeps an 8 MB run of zero-widths in a Write body from
153
- // throwing `RangeError: Maximum call stack size exceeded` out of this hook.
154
- if (hasLongRun(text)) return true;
155
- return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
156
- }
157
-
158
148
  /**
159
149
  * The de-ANSI'd `text`, or `text` itself when its escapes are display-only
160
- * colour a reader authored on purpose.
161
- *
162
- * The two questions are answered by two different views, deliberately.
163
- * `isBenignAnsiKinds` reads the kinds the strip actually REMOVED, so a sequence
164
- * that only reconstitutes mid-strip is judged as the sequence it becomes rather
165
- * than as the incomplete one it started as. `sgrCarriesPayload` reads the RAW
166
- * bytes, because those are the bytes a terminal renders on the preserve path —
167
- * it makes no second pass, so there is nothing to reconstitute there.
150
+ * colour a reader authored on purpose — the engine's `escapesPreservable`, the
151
+ * same answer the prompt gate gives, judged on the kinds THIS strip removed.
168
152
  *
169
153
  * Only COMPLETE tokens reach `kinds`: an introducer that opens no sequence is
170
154
  * left in place by `stripAnsiFully` (it rewrites no display), so the orphan arms
171
- * of `isBenignAnsiKinds` never decide anything here.
155
+ * of the inert-kind test never decide anything here.
172
156
  * @param {string} text
173
157
  * @returns {string}
174
158
  */
@@ -177,7 +161,7 @@ function stripTerminalControls(text) {
177
161
  const kinds = new Set();
178
162
  const deAnsi = stripAnsiFully(text, kinds);
179
163
  if (deAnsi === text) return text;
180
- if (isBenignAnsiKinds(kinds) && !sgrCarriesPayload(text)) return text;
164
+ if (escapesPreservable(text, kinds)) return text;
181
165
  return deAnsi;
182
166
  }
183
167
 
@@ -187,11 +171,36 @@ function stripTerminalControls(text) {
187
171
  // ZWSP goes), and removing a sequence makes invisibles adjacent that were not.
188
172
  // One round of each therefore leaves the composition unfinished. Each round
189
173
  // deletes at least one character, so the loop converges on its own; the bound is
190
- // a DoS guard. No residual sweep stands behind it — unlike the engine's
191
- // MAX_LAYER1_PASSES — so an unconverged value would persist a live sequence into
192
- // the file, and reaching the bound while still changing throws instead.
174
+ // a DoS guard. Reaching it still changing falls back to settleField, never to
175
+ // the unconverged value, which could persist a live sequence into the file.
193
176
  const MAX_FIELD_PASSES = 3;
194
177
 
178
+ /**
179
+ * The carve-out-free strip a field that did not converge falls back to: every
180
+ * invisible, then every escape and raw introducer, each where its protection
181
+ * is on — a fixed point of sanitizeField, on adversarial input only.
182
+ * @param {string} value the field as the caller sent it (for the leading-BOM rule)
183
+ * @param {string} cleaned the loop's last state
184
+ * @param {Set<string>} actions collects the protections that changed the field
185
+ * @returns {string}
186
+ */
187
+ function settleField(value, cleaned, actions) {
188
+ let settled = cleaned;
189
+ if (!readKnob("AGENT_SANITIZER_INVISIBLE_DISABLED")) {
190
+ const bulk = stripInvisibleWithReport(settled, value, {
191
+ carveOut: false,
192
+ }).cleaned;
193
+ if (bulk !== settled) actions.add("invisible characters");
194
+ settled = bulk;
195
+ }
196
+ if (!readKnob("AGENT_SANITIZER_TERMINAL_DISABLED")) {
197
+ const swept = stripTerminalFully(settled);
198
+ if (swept !== settled) actions.add("terminal-control sequences");
199
+ settled = swept;
200
+ }
201
+ return settled;
202
+ }
203
+
195
204
  // Returns the cleaned value plus the human-readable actions applied, or null if
196
205
  // the field is already clean. Each protection has its own opt-out (see the
197
206
  // header) so a deployment can keep one while dropping the other.
@@ -216,23 +225,31 @@ function sanitizeField(value) {
216
225
  }
217
226
  }
218
227
 
219
- if (
220
- !readKnob("AGENT_SANITIZER_INVISIBLE_DISABLED") &&
221
- isPayloadCapable(cleaned)
222
- ) {
223
- cleaned = stripInvisible(cleaned);
224
- actions.add("invisible characters");
228
+ // The engine's payloadVerdict on the de-ANSI'd view, as the prompt gate
229
+ // judges it: SGR the colour carve-out kept renders nothing, so it must not
230
+ // count as cover that buys preserved joiners and selectors.
231
+ if (!readKnob("AGENT_SANITIZER_INVISIBLE_DISABLED")) {
232
+ const view = stripAnsiFully(cleaned);
233
+ if (payloadVerdict(view).payloadCapable) {
234
+ // A payload forfeits the colour carve-out, so the strip reads the view
235
+ // the verdict read — unless the terminal opt-out keeps the escapes.
236
+ const source = readKnob("AGENT_SANITIZER_TERMINAL_DISABLED")
237
+ ? cleaned
238
+ : view;
239
+ const stripped = stripInvisibleWithReport(source, cleaned).cleaned;
240
+ if (source !== cleaned) actions.add("terminal-control sequences");
241
+ if (stripped !== source) actions.add("invisible characters");
242
+ cleaned = stripped;
243
+ }
225
244
  }
226
245
  if (cleaned === before) break;
227
- // This refusal is what blocks a still-changing value — one whose next round
246
+ // This fallback is what blocks a still-changing value — one whose next round
228
247
  // would remove another live sequence — from being written to the file.
229
248
  if (pass === MAX_FIELD_PASSES - 1)
230
- throw new Error(
231
- "authored content did not reach a fixed point within MAX_FIELD_PASSES",
232
- );
249
+ cleaned = settleField(value, cleaned, actions);
233
250
  }
234
251
 
235
- return actions.size > 0 ? { cleaned, actions: [...actions] } : null;
252
+ return cleaned === value ? null : { cleaned, actions: [...actions] };
236
253
  }
237
254
 
238
255
  /** @param {string[]} changed */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.60.3",
3
+ "version": "2.61.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": {
@@ -52,6 +52,7 @@
52
52
  "@stryker-mutator/core": "^9.6.1",
53
53
  "@stryker-mutator/tap-runner": "^9.6.1",
54
54
  "@types/node": "25.9.1",
55
+ "@unicode/unicode-17.0.0": "2.0.7",
55
56
  "acorn": "^8.18.0",
56
57
  "acorn-walk": "^8.3.5",
57
58
  "agent-sanitizer": "link:.",
@@ -247,6 +248,8 @@
247
248
  "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
248
249
  "build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
249
250
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
251
+ "gen:unicode": "node scripts/gen-unicode.mjs",
252
+ "gen:unicode-properties": "node scripts/gen-unicode-properties.mjs",
250
253
  "gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
251
254
  "gen:node-floor-lib": "node scripts/gen-node-floor-lib.mjs",
252
255
  "gen:hook-binaries": "node plugin/scripts/build-hook-binaries.mjs",
@@ -1,6 +1,6 @@
1
1
  {
2
- "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
3
- "unicode_version": "17.0",
2
+ "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from the UCD package at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
3
+ "unicode_version": "17.0.0",
4
4
  "extra_codepoints": [
5
5
  847,
6
6
  4447,
package/src/ansi.mjs CHANGED
@@ -515,15 +515,67 @@ export function scanAnsi(text) {
515
515
  }
516
516
 
517
517
  /**
518
- * The CONCEAL state after the SGR token `token` is applied to a terminal already
519
- * in state `concealed` — the state in which a terminal renders what follows as
520
- * blank while its bytes stay readable to anything reading the file.
518
+ * What a terminal's graphic rendition looks like after an SGR token: whether
519
+ * CONCEAL is on, whether BOLD is (which many terminals render as a brighter
520
+ * foreground), and the foreground/background colour as a comparable key — `i<n>`
521
+ * for a palette index (`31` and `38;5;1` are both `i1`), `rgb:<r>,<g>,<b>` for
522
+ * direct colour, `null` for the default or a colour this reader cannot size.
523
+ * @typedef {{ concealed: boolean, bold: boolean, fg: string | null, bg: string | null }} SgrRendition
524
+ */
525
+
526
+ /** The rendition of a terminal no SGR has touched (and of `ESC[0m`).
527
+ * @type {Readonly<SgrRendition>} */
528
+ export const DEFAULT_RENDITION = Object.freeze({
529
+ concealed: false,
530
+ bold: false,
531
+ fg: null,
532
+ bg: null,
533
+ });
534
+
535
+ const SGR_BOLD = 1;
536
+ const SGR_NORMAL_INTENSITY = 22;
537
+ const SGR_FOREGROUND = 38;
538
+ const SGR_BACKGROUND = 48;
539
+ const SGR_INDEXED = 5;
540
+ const SGR_DIRECT = 2;
541
+
542
+ /**
543
+ * The palette key a basic colour parameter sets on the layer at `base` (30
544
+ * foreground, 40 background), `null` for its default, undefined for none.
545
+ * @param {number} value @param {number} base
546
+ * @returns {string | null | undefined}
547
+ */
548
+ function basicColour(value, base) {
549
+ if (value >= base && value <= base + 7) return `i${value - base}`;
550
+ if (value >= base + 60 && value <= base + 67) return `i${value - base - 52}`;
551
+ if (value === base + 9) return null;
552
+ return undefined;
553
+ }
554
+
555
+ /**
556
+ * The colour key extended-colour arguments name (`5;n` palette, `2;r;g;b`
557
+ * direct, either separator), or `null` — never equal to another colour.
558
+ * @param {string[]} args the parameters after the selector, colour space first
559
+ * @returns {string | null}
560
+ */
561
+ function extendedColour(args) {
562
+ const space = Number(args[0]);
563
+ if (space === SGR_INDEXED && args.length >= 2) return `i${Number(args[1])}`;
564
+ if (space === SGR_DIRECT && args.length >= 4)
565
+ return `rgb:${args
566
+ .slice(-3)
567
+ .map((component) => Number(component))
568
+ .join(",")}`;
569
+ return null;
570
+ }
571
+
572
+ /**
573
+ * The rendition after the SGR token `token` is applied to a terminal already in
574
+ * `state`.
521
575
  *
522
- * A TRANSITION, not a property of the token: conceal is terminal state that
576
+ * A TRANSITION, not a property of the token: rendition is terminal state that
523
577
  * outlives the sequence that set it, so `ESC[8m` followed by `ESC[31m` is still
524
- * concealed — a per-token predicate would read the second token as "no conceal
525
- * here" and lose the state. Only `8` (set), `28` (reveal) and `0` (reset all)
526
- * move it, and the token's parameters are applied in order, so a later reveal or
578
+ * concealed. The token's parameters are applied in order, so a later reveal or
527
579
  * reset in the SAME token cancels an earlier `8`: `ESC[8;28;31m` renders red and
528
580
  * visible.
529
581
  *
@@ -531,33 +583,60 @@ export function scanAnsi(text) {
531
583
  * the digit. `38`/`48`/`58` take their colour arguments from the parameters that
532
584
  * FOLLOW them in the semicolon form, so `ESC[38;5;8m` is bright-black foreground
533
585
  * and its `8` is a palette index; a parameter carrying its arguments as ITU T.416
534
- * sub-parameters (`38:5:8`) is self-contained, so only its head counts and
535
- * nothing after it is consumed. A colour-space selector T.416 gives no argument
536
- * count for is malformed, and a terminal that ignores the colour form still
537
- * applies what follows it, so the scan CONTINUES from the next parameter rather
538
- * than consuming arguments it cannot size — reading one parameter too many costs
539
- * a token shape no emitter produces, while stopping there would hand
540
- * `ESC[38;9;8m` a pass.
586
+ * sub-parameters (`38:5:8`) is self-contained, so nothing after it is consumed.
587
+ * A colour-space selector T.416 gives no argument count for is malformed, and a
588
+ * terminal that ignores the colour form still applies what follows it, so the
589
+ * scan CONTINUES from the next parameter rather than consuming arguments it
590
+ * cannot size — stopping there would hand `ESC[38;9;8m` a pass.
541
591
  * @param {string} token a token {@link scanAnsi} classified {@link TOKEN_KIND.SGR}
542
- * @param {boolean} concealed the state before this token
543
- * @returns {boolean}
592
+ * @param {Readonly<SgrRendition>} state the rendition before this token
593
+ * @returns {SgrRendition}
544
594
  */
545
- export function sgrConcealState(token, concealed) {
595
+ export function sgrRendition(token, state) {
546
596
  // The parameter bytes: everything between the introducer (two characters in
547
597
  // the 7-bit `ESC [` form, one in the 8-bit C1 form) and the final `m`.
548
598
  const params = token
549
599
  .slice(token.charCodeAt(0) === ESC ? 2 : 1, -1)
550
600
  .split(";");
551
- let state = concealed;
601
+ let { concealed, bold, fg, bg } = state;
552
602
  for (let i = 0; i < params.length; i++) {
553
- const colonForm = params[i].includes(":");
603
+ const subParams = params[i].split(":");
554
604
  // An omitted parameter is 0 (`ESC[m` is `ESC[0m`), which `Number("")` gives.
555
- const value = Number(params[i].split(":")[0]);
556
- if (value === SGR_CONCEAL) state = true;
557
- else if (value === SGR_REVEAL || value === SGR_RESET) state = false;
558
- if (colonForm || !SGR_EXTENDED_COLOUR.has(value)) continue;
559
- const args = SGR_COLOUR_ARGS.get(Number(params[i + 1]));
560
- if (args !== undefined) i += 1 + args;
605
+ const value = Number(subParams[0]);
606
+ if (value === SGR_RESET) ({ concealed, bold, fg, bg } = DEFAULT_RENDITION);
607
+ else if (value === SGR_CONCEAL) concealed = true;
608
+ else if (value === SGR_REVEAL) concealed = false;
609
+ else if (value === SGR_BOLD) bold = true;
610
+ else if (value === SGR_NORMAL_INTENSITY) bold = false;
611
+ const foreground = basicColour(value, 30);
612
+ if (foreground !== undefined) fg = foreground;
613
+ const background = basicColour(value, 40);
614
+ if (background !== undefined) bg = background;
615
+ if (!SGR_EXTENDED_COLOUR.has(value)) continue;
616
+ const colonForm = subParams.length > 1;
617
+ const count = SGR_COLOUR_ARGS.get(Number(params[i + 1]));
618
+ if (!colonForm && count === undefined) continue; // unsized: consume nothing
619
+ const args = colonForm
620
+ ? subParams.slice(1)
621
+ : params.slice(i + 1, i + 2 + /** @type {number} */ (count));
622
+ if (!colonForm) i += args.length;
623
+ const colour = extendedColour(args);
624
+ if (value === SGR_FOREGROUND) fg = colour;
625
+ else if (value === SGR_BACKGROUND) bg = colour;
561
626
  }
562
- return state;
627
+ return { concealed, bold, fg, bg };
628
+ }
629
+
630
+ /**
631
+ * True when text drawn in `rendition` is invisible to a human because its
632
+ * foreground IS its background (`ESC[30;40m`, `ESC[38;5;7;48;5;7m`). Only an
633
+ * unambiguous match counts: a default colour is unknown, so it matches nothing,
634
+ * and BOLD over a basic colour is drawn brighter by many terminals, so
635
+ * `ESC[1;30;40m` is not called hidden — the precision direction.
636
+ * @param {Readonly<SgrRendition>} rendition
637
+ * @returns {boolean}
638
+ */
639
+ export function foregroundMatchesBackground({ bold, fg, bg }) {
640
+ if (fg === null || fg !== bg) return false;
641
+ return !(bold && /^i[0-7]$/u.test(fg));
563
642
  }
@@ -1,19 +1,19 @@
1
1
  /**
2
- * GENERATED by scripts/gen-invisible-charset.mjs from Node's Unicode data
3
- * (\p{Cf}, Unicode 17.0) — DO NOT EDIT.
2
+ * GENERATED by scripts/gen-invisible-charset.mjs from the pinned UCD
3
+ * (General_Category=Cf, Unicode 17.0.0) — DO NOT EDIT.
4
4
  *
5
5
  * The general-category Cf code points, PINNED at generation time. invisible.mjs
6
- * strips exactly this set instead of testing \p{Cf} live, and the Python port
6
+ * strips exactly this set instead of asking the runtime for Cf, and the Python port
7
7
  * reads the SAME set from data/invisible-charset.json's `cf_codepoints`, so both
8
8
  * layers strip an identical Cf set regardless of each runtime's own Unicode
9
9
  * version. Regenerate with `node scripts/gen-invisible-charset.mjs`;
10
- * test/invisible-charset.test.mjs fails if this drifts from Node's \p{Cf}.
10
+ * test/invisible-charset.test.mjs fails if this drifts from the pinned UCD.
11
11
  */
12
12
 
13
- export const UNICODE_VERSION = "17.0";
13
+ export const UNICODE_VERSION = "17.0.0";
14
14
 
15
15
  // GENERATED Unicode data, not hand-written logic. Its correctness is pinned by
16
- // the SSOT round-trip in test/invisible-charset.test.mjs (re-enumerate \p{Cf},
16
+ // the SSOT round-trip in test/invisible-charset.test.mjs (re-derive the Cf set,
17
17
  // then assert every code point matches) — Stryker skips the numeric literals.
18
18
  // Stryker disable all
19
19
  /** @type {readonly number[]} Sorted ascending. */
package/src/gates.mjs CHANGED
@@ -88,7 +88,7 @@ export function needsUrlScan(text) {
88
88
  // can't be retried at every offset; the atlasv1 arm in SECRET_HINT does the same.
89
89
  /** @type {RegExp} */
90
90
  export const SECRET_HINT =
91
- /secret|token|password|passwd|pwd|bearer|credential|authorization|contrase[nñ]a|-----BEGIN|(?:api|auth|service|account|db|database|priv|private|client|access)[_-]?key|(?:db|database|key)[_-]?pass|(?:A3T|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9]|github_pat_|gl[a-z]{2,12}-[0-9A-Za-z_-]{20}|sk-ant-|AIza[0-9A-Za-z_-]{35}|sk_live_|sk_test_|rk_live_|rk_test_|xox[bpasr]-|eyJ[A-Za-z0-9]|do[opr]_v1_[a-f0-9]{16}|v1\.0-[a-f0-9]{24}-|hv[sb]\.[A-Za-z0-9_-]{20}|(?<![a-z0-9])[a-z0-9]{14}\.atlasv1\.|sk-or-v1-[0-9a-f]{16}|gsk_[A-Za-z0-9]{16}|xai-[A-Za-z0-9]{16}|r8_[A-Za-z0-9]{16}/i;
91
+ /secret|token|password|passwd|pwd|bearer|credential|authorization|contrase[nñ]a|-----BEGIN|(?:api|auth|service|account|db|database|priv|private|client|access)[_-]?key|(?:db|database|key)[_-]?pass|(?:A3T|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9]|github_pat_|gl[a-z]{2,12}-[0-9A-Za-z_-]{20}|sk-ant-|AIza[0-9A-Za-z_-]{35}|sk_live_|sk_test_|rk_live_|rk_test_|xox[bpasr]-|eyJ[A-Za-z0-9]|do[opr]_v1_[a-f0-9]{16}|v1\.0-[a-f0-9]{24}-|hv[sb]\.[A-Za-z0-9_-]{20}|(?<![a-z0-9])[a-z0-9]{14}\.atlasv1\.|sk-or-|gsk_[A-Za-z0-9]{16}|xai-[A-Za-z0-9]{16}|r8_[A-Za-z0-9]{16}/i;
92
92
 
93
93
  // Second alternation (see SECRET_HINT): kept a separate literal so a redos
94
94
  // analyzer vets each alternation in isolation.
package/src/index.mjs CHANGED
@@ -26,10 +26,12 @@ import { sanitizeText } from "./output.mjs";
26
26
  export {
27
27
  applyLayer1,
28
28
  applyLayer1WellFormed,
29
+ escapesPreservable,
29
30
  isBenignAnsi,
30
31
  isBenignAnsiKinds,
31
32
  normalizeLoneSurrogates,
32
33
  stripAnsiFully,
34
+ stripTerminalFully,
33
35
  sgrCarriesPayload,
34
36
  SGR_RUN_THRESHOLD,
35
37
  LONE_SURROGATE_RE,
@@ -40,7 +40,8 @@ import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
40
40
  import {
41
41
  findLongRuns,
42
42
  SCATTERED_THRESHOLD,
43
- countPayloadInvisible,
43
+ payloadInvisibleView,
44
+ payloadVerdict,
44
45
  stripInvisible,
45
46
  } from "./invisible.mjs";
46
47
  import { excludeNodeModules } from "./claude-context.mjs";
@@ -209,15 +210,21 @@ export function decodeRun(run) {
209
210
  }
210
211
 
211
212
  /**
212
- * Scan a file's text for hidden-Unicode injection. Reports each long invisible
213
+ * Scan a file's text for hidden-Unicode injection. Reports each payload long
213
214
  * run (with its decoded payload) plus a single scattered-chars finding when the
214
- * non-run invisible count crosses the threshold-evasion floor.
215
+ * effective invisible count outside those runs crosses the threshold-evasion
216
+ * floor.
217
+ *
218
+ * Whether there is anything to report is {@link payloadVerdict}'s call, the
219
+ * one every other surface makes.
215
220
  * @param {string} content
216
221
  * @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>}
217
222
  * `line` is the 1-based line of a long-run finding, or `null` for the
218
223
  * whole-file scattered-chars finding (not tied to a single line).
219
224
  */
220
225
  export function scanText(content) {
226
+ const verdict = payloadVerdict(content);
227
+ if (!verdict.payloadCapable) return [];
221
228
  const findings = [];
222
229
  let runChars = 0;
223
230
  // The line number is carried forward across runs. Deriving it per run from
@@ -228,7 +235,13 @@ export function scanText(content) {
228
235
  // order, so this scan only ever moves forward.
229
236
  let line = 1;
230
237
  let counted = 0;
231
- for (const run of findLongRuns(content)) {
238
+ // The verdict keeps only the first run; the rest come off the payload view,
239
+ // which is index-aligned with `content` and masks preserved invisibles.
240
+ const runs =
241
+ verdict.firstRun === null
242
+ ? []
243
+ : findLongRuns(payloadInvisibleView(content));
244
+ for (const run of runs) {
232
245
  for (; counted < run.index; counted++)
233
246
  if (content.charCodeAt(counted) === NEWLINE) line++;
234
247
  runChars += run.charCount;
@@ -240,27 +253,9 @@ export function scanText(content) {
240
253
  }
241
254
 
242
255
  // Threshold-evasion: scattered invisible chars not in a long run can still be
243
- // a payload. Always evaluated; chars already in a run are excluded so they
244
- // aren't double-counted. countPayloadInvisible is invisible.mjs's carve-out
245
- // counter (the SSOT the stripper itself gates on): it discounts every
246
- // invisible that does real rendering work — emoji presentation selectors
247
- // (VS16 *and* VS15) on a real pictograph, emoji-sequence ZWJ, and linguistic
248
- // ZWNJ/ZWJ between cursive letters or after a virama. A hand-rolled emoji-only
249
- // mirror lived here before and over-counted linguistic joiners and VS15, so a
250
- // ZWNJ-dense Persian doc or a doc of text-presentation hearts (❤︎) tripped a
251
- // scattered false positive on content stripInvisible would preserve.
252
- //
253
- // Asymmetry (deliberate, benign): the minuend discounts preserved
254
- // selectors/joiners EVERYWHERE, while `runChars` is each run's RAW length. A
255
- // long run is ≥LONG_RUN_THRESHOLD *consecutive* invisibles, and a preserved
256
- // selector/joiner is always flanked by a visible neighbor — so it cannot sit
257
- // inside such a run, and the two counts describe disjoint chars in practice.
258
- // In the pathological case that they don't (e.g. a run of stacked VS16), the
259
- // raw `runChars` subtracts at most a few more than the minuend added, biasing
260
- // `scattered` slightly LOW — a false negative, the precision-favoring
261
- // direction, never a spurious finding. `scattered` may even go negative; the
262
- // `>=` gate treats that as "no scatter", which is correct.
263
- const scattered = countPayloadInvisible(content) - runChars;
256
+ // a payload. Every run above is made of invisibles the verdict's effective
257
+ // count includes, so subtracting them leaves exactly the unreported ones.
258
+ const scattered = verdict.invisibleCount - runChars;
264
259
  if (scattered >= SCATTERED_THRESHOLD) {
265
260
  findings.push({
266
261
  line: null, // whole-file finding: scattered chars aren't tied to one line