agent-sanitizer 2.19.2 → 2.19.4

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/layer1.mjs CHANGED
@@ -13,98 +13,16 @@
13
13
  * point where a lone surrogate would otherwise corrupt a match or a parse.
14
14
  */
15
15
  import { stripInvisibleWithReport, CATEGORY } from "./invisible.mjs";
16
+ import { CONTROL_INTRODUCER_SOURCE, scanAnsi, TOKEN_KIND } from "./ansi.mjs";
16
17
 
17
- // Raw control introducers that must not survive: 7-bit ESC (U+001B) and the
18
- // entire 8-bit C1 control block (U+0080–U+009F) — which includes CSI (U+009B),
19
- // the string introducers DCS/SOS/OSC/PM/APC (U+0090/0098/009D/009E/009F), and ST
20
- // (U+009C). All are category Cc, so the invisible-char pass (which targets Cf /
21
- // variation / blank fillers) never removes them; this residual sweep is the
22
- // guarantee that none survives. Sweeping the whole C1 block — not just the
23
- // introducers the ANSI grammar above names — fails closed: a DCS/SOS/PM/APC
24
- // string the grammar does not consume still loses its introducer and terminator
25
- // here, so no terminal can hide-render its body as a control payload.
26
- // eslint-disable-next-line no-control-regex -- matching the raw introducers is the point
27
- const CONTROL_INTRODUCER_RE = /[\u001b\u0080-\u009f]/g;
18
+ // The ANSI grammar and the introducer charset live in ./ansi.mjs so this module
19
+ // and invisible.mjs (which owns the public isSgrOnly / SGR_RE and cannot import
20
+ // this one) scan with the SAME tokenizer.
28
21
 
29
- // An OSC (Operating System Command) string is `<introducer> … <terminator>`.
30
- // Introducer: 7-bit `ESC]` or 8-bit C1 OSC (U+009D). Terminator: ST (`ESC\` or
31
- // 8-bit C1 ST U+009C) OR the legacy BEL (U+0007). The body is everything up to
32
- // the terminator — a title, a clickable-hyperlink URL, a clipboard write — i.e.
33
- // attacker-controlled PAYLOAD TEXT. Matching the introducer alone (leaving the
34
- // body) would let that payload survive into the model's view, so the OSC branch
35
- // consumes the introducer, the whole body, AND the terminator as one unit.
36
- //
37
- // Three alternatives, tried in order:
38
- // 1. a properly TERMINATED string — a body of bytes that no terminator can
39
- // start with (the negated class makes that run unambiguous and
40
- // backtrack-free), then a terminator (ST or BEL).
41
- // 2. an ABORTED string — the body runs up to (but does NOT consume) an
42
- // interior bare ESC or a nested C1-OSC introducer (U+009D) that is not
43
- // itself part of a valid terminator. Per ECMA-48/xterm, a bare ESC (one
44
- // not immediately followed by `\` to form ST) aborts the OSC string in
45
- // progress — the terminal drops back to processing that ESC as the start
46
- // of a NEW sequence, and everything after it is normal text/escapes, not
47
- // part of this OSC's payload. Consuming only up to the lookahead (not
48
- // the ESC/U+009D itself) leaves it for the next position in this same
49
- // `.replace(ANSI_RE, ...)` scan to match as its own sequence (or, if it
50
- // doesn't complete one, for the residual C1 sweep in applyLayer1 to
51
- // remove just that one introducer byte) — so an interior ESC can no
52
- // longer delete the rest of the document (it used to fall through to
53
- // alternative 3 below and consume everything to EOS).
54
- // 3. anything else from the introducer to END-OF-STRING (`[\s\S]*$`) — the
55
- // fail-closed catch-all for a GENUINELY unterminated string: no ST, BEL,
56
- // interior ESC, or nested OSC intro anywhere in the remainder, so there
57
- // is truly nothing left to hand to a later position. Reached only when
58
- // alternatives 1 and 2 both fail to find their respective triggers.
59
- // All three are linear (bounded lookahead / no nested quantifiers), so the
60
- // branch stays linear.
61
- const OSC_INTRO = "(?:\\u001b\\]|\\u009d)";
62
- const OSC_TERM = "(?:\\u001b\\\\|\\u009c|\\u0007)";
63
- const OSC_BODY = "[^\\u0007\\u001b\\u009c\\u009d]";
64
- const OSC_BRANCH = `${OSC_INTRO}(?:${OSC_BODY}*${OSC_TERM}|${OSC_BODY}*(?=[\\u001b\\u009d])|${OSC_BODY}*$)`;
65
-
66
- // CSI / two-byte ESC sequences (cursor moves, erase, SGR color, charset/DEC
67
- // selectors): an introducer, a bounded private-intro run, optional numeric
68
- // params, and a single final byte. Not an enforcement boundary on its own — any
69
- // introducer this declines to match is still removed by the residual sweep in
70
- // applyLayer1 — but matching the whole sequence keeps the common case one clean
71
- // deletion (and avoids a lone-ESC residual on every styled line).
72
- //
73
- // The private-intro class is BOUNDED ({0,12}, not *) on purpose: ; and # live
74
- // in both this class and the parameter group that follows, so an unbounded *
75
- // here lets a ;#;#... run be split between the two quantifiers — O(n^2)
76
- // backtracking on an ESC;#;#... string that never completes a sequence
77
- // (CodeQL js/polynomial-redos). A constant bound makes the intro a constant
78
- // factor, so the whole match is linear; a real sequence never carries more than
79
- // a couple of intro bytes.
80
- //
81
- // Params allow BOTH `;` (standard parameter separator) and `:` (ITU T.416
82
- // colon-separated SGR sub-parameters, e.g. truecolor `ESC[38:2:255:0:0m` as
83
- // emitted by tmux/kitty/mintty) — legitimate, display-only ANSI that must not
84
- // leave a colon-parameter residue behind.
85
- //
86
- // The final-byte class deliberately excludes `\d`: per ECMA-48, CSI final
87
- // bytes occupy 0x40–0x7E (letters and a handful of punctuation) while digits
88
- // are PARAMETER bytes and can never terminate a sequence — an unterminated
89
- // `ESC[` therefore must not be allowed to eat trailing visible digits (e.g.
90
- // `ESC[2024 report` is NOT `ESC[` + final-byte `2` + literal `024 report`; it
91
- // is an incomplete CSI intro that the residual sweep in applyLayer1 cleans up,
92
- // leaving "2024 report" intact). `=`, `<`, `>` are excluded for the same
93
- // reason: 0x3C–0x3F (`<=>?`) are private PARAMETER-prefix bytes, not final
94
- // bytes, per ECMA-48 § 5.4 — `?` already lives in the private-intro class
95
- // above; `<=>` were never valid finals and including them let a private-marker
96
- // sequence terminate one byte too early. `~` (0x7E) IS a real final byte (vt220
97
- // function-key sequences, e.g. `ESC[3~` for Delete) and is kept.
98
- const CSI_BRANCH =
99
- "[\\u001b\\u009b][[()#;?]{0,12}(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[A-PR-TZcf-ntqry~])";
100
-
101
- // Full ANSI escape grammar (OSC first so `ESC]` / C1-OSC is consumed as a whole
102
- // string, not split by the CSI branch), not just SGR: the Layer-1 guarantee is
103
- // that no control introducer and no OSC payload survives, and a cursor-move or
104
- // erase sequence is as much a display-spoofing hazard as a color one. Built from
105
- // `\uXXXX`-escaped string parts via `new RegExp`, so no raw control byte sits in
106
- // the source (no no-control-regex disable needed).
107
- const ANSI_RE = new RegExp(`(?:${OSC_BRANCH}|${CSI_BRANCH})`, "gu");
22
+ // The residual sweep: every raw control introducer, whatever the grammar made of
23
+ // it. This — not the sequence matching — is the guarantee that no introducer
24
+ // survives Layer 1.
25
+ const CONTROL_INTRODUCER_RE = new RegExp(CONTROL_INTRODUCER_SOURCE, "g");
108
26
 
109
27
  // Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
110
28
  // high). Normalized before any HTML parser, which throws on a stray byte —
@@ -114,6 +32,29 @@ export const LONE_SURROGATE_RE =
114
32
 
115
33
  const MAX_ANSI_PASSES = 3;
116
34
 
35
+ /**
36
+ * Splice out every complete ANSI sequence {@link scanAnsi} finds — SGR, other
37
+ * CSI/two-byte escapes, and whole OSC strings. An ORPHAN introducer (one that
38
+ * starts no sequence) is deliberately LEFT: deleting it here would drop the
39
+ * `ESC` out of `ESC`<ZWSP>`[32m` before the invisible pass could reconstitute
40
+ * the sequence, turning a hidden control into visible `[32m` in the model's
41
+ * view. Orphans are removed by applyLayer1's residual sweep, once no
42
+ * reconstitution is possible.
43
+ * @param {string} text
44
+ * @returns {string}
45
+ */
46
+ function stripAnsiOnce(text) {
47
+ let out = "";
48
+ let last = 0;
49
+ for (const token of scanAnsi(text)) {
50
+ if (token.kind === TOKEN_KIND.ORPHAN) continue;
51
+ out += text.slice(last, token.start);
52
+ last = token.end;
53
+ }
54
+ if (last === 0) return text;
55
+ return out + text.slice(last);
56
+ }
57
+
117
58
  /**
118
59
  * Strip ANSI escape sequences to a fixed point. Removing one sequence can
119
60
  * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
@@ -131,54 +72,98 @@ const MAX_ANSI_PASSES = 3;
131
72
  */
132
73
  export function stripAnsiFully(input) {
133
74
  let prev = input;
134
- let out = prev.replace(ANSI_RE, "");
75
+ let out = stripAnsiOnce(prev);
135
76
  for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
136
77
  prev = out;
137
- out = prev.replace(ANSI_RE, "");
78
+ out = stripAnsiOnce(prev);
138
79
  }
139
80
  return out;
140
81
  }
141
82
 
83
+ // How many times the {ANSI strip, invisible strip} composition may re-run before
84
+ // the sweep is forced. Each iteration deletes at least one character, so the
85
+ // loop terminates on its own; the bound is a DoS guard on the same quadratic
86
+ // argument as MAX_ANSI_PASSES (n rounds of O(n) scans on adversarial input).
87
+ // Three rounds is what the deepest REAL reconstitution needs — strip
88
+ // invisibles, remove the ANSI they hid, strip the joiners that ANSI removal
89
+ // made adjacent, confirm — so four leaves a round of margin. Deeper nesting is
90
+ // not reachable through the carve-out: preserving a joiner requires cursive or
91
+ // emoji neighbours on BOTH sides, so a preserved joiner can never sit inside an
92
+ // escape sequence, and every joiner that hides one is therefore stripped by the
93
+ // FIRST invisible pass. Past the bound the composition simply stops early — the
94
+ // final sweep still holds the no-introducer guarantee, and what is left degrades
95
+ // to visible text (the fail-open direction), exactly as with MAX_ANSI_PASSES.
96
+ const MAX_LAYER1_PASSES = 4;
97
+
142
98
  /**
143
99
  * Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
144
100
  * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
145
101
  * block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
146
102
  *
147
- * Removing an invisible character can reconstitute an escape its split hid from
148
- * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
149
- * invisible pass — but only when stripInvisible changed something, since
150
- * reconstitution is impossible otherwise and the re-strip is a wasted pass on
151
- * the hot clean path. The ANSI strip still cannot match an *incomplete*
152
- * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
153
- * removed from a nested split), so a final sweep removes every residual raw
154
- * introducer outright — that sweep, not the regex matching, is the guarantee
155
- * that no control introducer survives. `deAnsi` is the ANSI strip of the
156
- * original (invisible runs intact), the scope a LONG_RUN payload check needs.
103
+ * The two passes FEED each other in both directions, so neither ordering is
104
+ * enough on its own and the composition is iterated to a fixed point instead:
105
+ * removing an invisible char reconstitutes an escape its split hid
106
+ * (`ESC`<ZWSP>`[32m` → `ESC[32m`), and removing an escape makes two invisibles
107
+ * ADJACENT that were not (`م ZWJ ESC[m ZWJ م` → a joiner run the invisible pass
108
+ * classifies as a payload channel rather than linguistic). A pipeline with a
109
+ * fixed number of alternations always leaves one of those unanswered — this used
110
+ * to re-strip ANSI after the invisible pass and stop, so `applyLayer1` was not
111
+ * idempotent and the rehydrator's "re-cleaning reproduces the view" assumption
112
+ * did not hold.
113
+ *
114
+ * The residual sweep runs only once the composition is STABLE, because sweeping
115
+ * an introducer early destroys the sequence a later ANSI pass would have removed
116
+ * whole, promoting a hidden control to visible text. A final UNCONDITIONAL sweep
117
+ * follows the loop so the no-raw-introducer guarantee does not depend on the
118
+ * pass bound.
119
+ *
120
+ * `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
121
+ * scope a LONG_RUN payload check needs — not an intermediate of the loop.
157
122
  * @param {string} text
158
123
  * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
159
124
  */
160
125
  export function applyLayer1(text) {
161
126
  const deAnsi = stripAnsiFully(text);
162
- // stripInvisibleWithReport returns `found` for exactly the categories it
163
- // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a strip,
164
- // and the leading-BOM exception is already handled inside it. Pass the ORIGINAL
165
- // `text` so a BOM that was interior before the ANSI strip (`ESC[m + interior U+FEFF`, now at
166
- // index 0 of `deAnsi`) is treated as interior and stripped, not preserved.
167
- const { cleaned: afterInvis, found } = stripInvisibleWithReport(deAnsi, text);
168
- let ansiFound = deAnsi.length !== text.length;
127
+ /** @type {Set<string>} Union of the categories every iteration reported. */
128
+ const found = new Set();
129
+ let cleaned = text;
130
+ let ansiFound = false;
169
131
 
170
- let cleaned = afterInvis;
171
- if (afterInvis !== deAnsi) {
172
- const reStripped = stripAnsiFully(afterInvis);
173
- if (reStripped.length !== afterInvis.length) ansiFound = true;
174
- cleaned = reStripped;
132
+ for (let pass = 0; pass < MAX_LAYER1_PASSES; pass++) {
133
+ const afterAnsi = pass === 0 ? deAnsi : stripAnsiFully(cleaned);
134
+ if (afterAnsi !== cleaned) ansiFound = true;
135
+ // stripInvisibleWithReport returns `found` for exactly the categories it
136
+ // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a
137
+ // strip. The second argument stays the ORIGINAL `text` on every iteration:
138
+ // the leading-BOM exception is defined against what the user actually sent,
139
+ // so a BOM that was interior before an ANSI strip shifted it to index 0 is
140
+ // treated as interior and stripped, not preserved.
141
+ const { cleaned: afterInvis, found: passFound } = stripInvisibleWithReport(
142
+ afterAnsi,
143
+ text,
144
+ );
145
+ for (const category of passFound) found.add(category);
146
+ if (afterInvis !== cleaned) {
147
+ cleaned = afterInvis;
148
+ continue;
149
+ }
150
+ // {ANSI, invisible} is stable: nothing left can reconstitute, so the
151
+ // residual introducers can be swept without hiding a sequence from a later
152
+ // pass. A sweep that changes the text feeds one more round (removing an
153
+ // introducer can make invisibles adjacent, exactly as removing a sequence
154
+ // can); one that changes nothing means the whole composition has converged.
155
+ const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
156
+ if (swept === cleaned) break;
157
+ cleaned = swept;
158
+ ansiFound = true;
175
159
  }
160
+
176
161
  const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
177
162
  if (swept !== cleaned) {
178
163
  cleaned = swept;
179
164
  ansiFound = true;
180
165
  }
181
166
 
182
- if (ansiFound) found.push(CATEGORY.ANSI);
183
- return { cleaned, deAnsi, found };
167
+ if (ansiFound) found.add(CATEGORY.ANSI);
168
+ return { cleaned, deAnsi, found: [...found] };
184
169
  }
package/src/output.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  import { CATEGORY, describeStripped, isSgrOnly } from "./invisible.mjs";
28
28
  import { HTML_TAG_PRESENT, MD_LINK_HINT } from "./gates.mjs";
29
29
  import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
30
+ import { orderedMatches, spliceOrdered } from "./view-map.mjs";
30
31
 
31
32
  /**
32
33
  * Closed enum of LIBRARY-OWNED Layer-5 warning codes — the ONLY warning values
@@ -206,22 +207,38 @@ export function describeWarned(warned) {
206
207
  /**
207
208
  * Delete each verbatim span in `spans` from `text`. The secure Layer-5
208
209
  * primitive: a filter can only ask for deletions, so this can never inject
209
- * bytes. Returns the new text and how many distinct span-occurrences were
210
- * removed (0 when no span was present).
210
+ * bytes. Returns the new text and how many span occurrences were removed (0
211
+ * when no span was present).
212
+ *
213
+ * Every occurrence is located in the ORIGINAL `text` and the deletions applied
214
+ * in one ordered pass ({@link spliceOrdered}), so every removed byte lies inside
215
+ * a match some span had in the INPUT. Deleting span-by-span with a
216
+ * chained `split`/`join` would not hold that line: an earlier deletion joins the
217
+ * bytes on either side of it and can CREATE a match for a later span that never
218
+ * occurred in the input — `deleteVerbatimSpans("PRE-XX-POST", ["-XX-", "PREPOST"])`
219
+ * then deletes the whole document. That would widen the Layer-5 seam's blast
220
+ * radius (see the module doc) from "a compromised filter can at most remove the
221
+ * content it named" to "it can remove content it never named".
222
+ *
223
+ * Overlapping spans are resolved first-match-wins, so `removed` counts the
224
+ * occurrences actually spliced out, never a double-count of the same bytes.
211
225
  * @param {string} text
212
226
  * @param {string[]} spans
213
227
  * @returns {{ text: string, removed: number }}
214
228
  */
215
229
  export function deleteVerbatimSpans(text, spans) {
216
- let out = text;
217
- let removed = 0;
218
- for (const span of spans) {
219
- if (!span) continue;
220
- const parts = out.split(span);
221
- removed += parts.length - 1;
222
- out = parts.join("");
223
- }
224
- return { text: out, removed };
230
+ // Keep only non-empty STRING spans. The filter is untrusted JS, not a
231
+ // type-checked caller, so the array can hold anything: `indexOf(123)` would
232
+ // silently match the literal text "123" (deleting content the filter never
233
+ // named), and `occurrences` steps by `needle.length` — `undefined` for a
234
+ // number — making `indexOf(needle, NaN)` clamp back to the same index and
235
+ // loop forever. Fail open on a malformed entry rather than mangle bytes or
236
+ // hang the pipeline.
237
+ const usable = spans.filter(
238
+ (span) => typeof span === "string" && span !== "",
239
+ );
240
+ const spliced = spliceOrdered(text, orderedMatches(text, usable), () => "");
241
+ return { text: spliced.text, removed: spliced.spans.length };
225
242
  }
226
243
 
227
244
  /**
package/src/prompt.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  isSgrOnly,
28
28
  } from "./invisible.mjs";
29
29
  import { stripAnsiFully } from "./layer1.mjs";
30
+ import { CONTROL_INTRODUCER_SOURCE } from "./ansi.mjs";
30
31
 
31
32
  // Every raw ANSI control a prompt can carry: 7-bit ESC (U+001B) and the entire
32
33
  // 8-bit C1 block (U+0080-U+009F). Gating on ESC alone -- or on only CSI/OSC --
@@ -34,12 +35,14 @@ import { stripAnsiFully } from "./layer1.mjs";
34
35
  // (CSI erase), `U+009D 0;...BEL` (OSC), or the string introducers DCS (U+0090),
35
36
  // SOS (U+0098), PM (U+009E), APC (U+009F): Layer 1 strips it, dropping the
36
37
  // invisible count to zero, so the prompt reads clean and passes. This gate must
37
- // match Layer 1's residual sweep (CONTROL_INTRODUCER_RE) exactly -- no raw C1
38
- // control belongs in a legitimate prompt (the SGR color carve-out is applied
39
- // separately, after SGR removal), so the whole block is gated, not a hand-picked
40
- // subset that lets DCS/SOS/PM/APC through.
41
- // eslint-disable-next-line no-control-regex -- the raw control introducers are exactly what we detect
42
- const ANSI_INTRODUCER = /[\u001b\u0080-\u009f]/;
38
+ // match Layer 1's residual sweep exactly -- no raw C1 control belongs in a
39
+ // legitimate prompt (the SGR color carve-out is applied separately, after SGR
40
+ // removal), so the whole block is gated, not a hand-picked subset that lets
41
+ // DCS/SOS/PM/APC through. Built from the SHARED charset source instead of a
42
+ // third hand-written copy: keeping the copies in step used to be a prose
43
+ // obligation recorded in this very comment, and two of the three spelled ESC
44
+ // differently, so even a grep-based drift check would have missed a divergence.
45
+ const ANSI_INTRODUCER = new RegExp(CONTROL_INTRODUCER_SOURCE);
43
46
 
44
47
  /**
45
48
  * True when every ANSI introducer in `prompt` belongs to a display-only SGR
package/src/rehydrate.mjs CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  occurrences,
52
52
  overlapAwareCount,
53
53
  orderedMatches,
54
+ spliceOrdered,
54
55
  alignDeletions,
55
56
  resolveSpan,
56
57
  rehydrateNewString,
@@ -415,9 +416,9 @@ async function rehydrateWrite(ti, view, io, hint) {
415
416
  };
416
417
 
417
418
  // Resolve each of this file's placeholder texts to its single secret first,
418
- // then splice in ONE ordered pass (R6). A chained `out.split(ph).join(secret)`
419
- // per placeholder is unsound: an inserted secret whose bytes contain a later
420
- // placeholder text would be re-matched and corrupted by the next split.
419
+ // then splice in ONE ordered pass (R6) via the shared `spliceOrdered` — see
420
+ // its doc for why a chained `out.split(ph).join(secret)` per placeholder is
421
+ // unsound.
421
422
  const valueByPh = new Map();
422
423
  for (const phText of texts) {
423
424
  const produced = view.pairs.filter((pair) => pair.placeholder === phText);
@@ -438,23 +439,15 @@ async function rehydrateWrite(ti, view, io, hint) {
438
439
  };
439
440
  valueByPh.set(phText, values[0]);
440
441
  }
441
- const matches = orderedMatches(ti.content, texts);
442
- let out = "";
443
- let last = 0;
444
- // Byte ranges in `out` occupied by the substituted secret values. A hint
445
- // occurrence inside one of these is a pathological secret whose bytes contain
446
- // the hint prefix, NOT a placeholder the model pasted — so it is excluded from
447
- // the foreign-placeholder scan below.
448
- const secretSpans = [];
449
- for (const match of matches) {
450
- const secret = valueByPh.get(match.text);
451
- out += ti.content.slice(last, match.index);
452
- const secretStart = out.length;
453
- out += secret;
454
- secretSpans.push({ start: secretStart, end: out.length });
455
- last = match.index + match.text.length;
456
- }
457
- out += ti.content.slice(last);
442
+ // `secretSpans`: byte ranges in `out` occupied by the substituted secret
443
+ // values. A hint occurrence inside one of these is a pathological secret whose
444
+ // bytes contain the hint prefix, NOT a placeholder the model pasted — so it is
445
+ // excluded from the foreign-placeholder scan below.
446
+ const { text: out, spans: secretSpans } = spliceOrdered(
447
+ ti.content,
448
+ orderedMatches(ti.content, texts),
449
+ (match) => valueByPh.get(match.text),
450
+ );
458
451
  const secrets = [...valueByPh.values()];
459
452
 
460
453
  // R3: the new content may mix a valid same-file placeholder (substituted
package/src/view-map.mjs CHANGED
@@ -223,9 +223,15 @@ export function resolveSpan(
223
223
  }
224
224
 
225
225
  /**
226
- * All occurrences of any needle in `text`, ordered by position. Placeholder
227
- * texts never substring-overlap one another (each ends in "]" right after its
228
- * distinguishing label), so the sorted matches are non-overlapping.
226
+ * All occurrences of any needle in `text`, ordered by position. Every index is
227
+ * computed against the ORIGINAL `text`, so the caller can splice them in one
228
+ * pass ({@link spliceOrdered}). Redaction placeholder texts never
229
+ * substring-overlap one another (each ends in "]" right after its
230
+ * distinguishing label), so for that caller the sorted matches are also
231
+ * non-overlapping; needles from an untrusted source (a Layer-5 filter's
232
+ * removeSpans) can overlap, which spliceOrdered resolves first-match-wins.
233
+ * Distinct needles matching at the SAME index keep `needles` order (Array#sort
234
+ * is stable), so first-match-wins is deterministic.
229
235
  * @param {string} text
230
236
  * @param {string[]} needles
231
237
  * @returns {{text: string, index: number}[]}
@@ -238,6 +244,47 @@ export function orderedMatches(text, needles) {
238
244
  return out.sort((left, right) => left.index - right.index);
239
245
  }
240
246
 
247
+ /**
248
+ * Replace every match in `matches` with `replacementFor(match, i)` in a SINGLE
249
+ * ordered pass over `text`. THE splice primitive for this codebase — the sole
250
+ * sound way to substitute several needles at once.
251
+ *
252
+ * A chained `text.split(needle).join(value)` per needle is unsound in both
253
+ * directions, which is why no caller may hand-roll one:
254
+ * - substitution: an inserted value whose bytes contain a LATER needle is
255
+ * re-matched by the next split and corrupted (or partially exposed);
256
+ * - deletion: an earlier deletion joins the bytes on either side of it and
257
+ * can CREATE a later needle's match, deleting text that needle never
258
+ * matched in the input ("PRE-XX-POST" minus "-XX-" yields "PREPOST").
259
+ * Because every index in `matches` is measured against the original `text`,
260
+ * this pass only ever touches bytes the caller actually matched.
261
+ *
262
+ * Overlapping matches are resolved first-match-wins: a match starting before
263
+ * the previous one ended is skipped, never spliced at a shifted offset.
264
+ * `i` is the match's index in `matches` (stable across skips) so a caller
265
+ * pairing matches positionally with its own array stays aligned.
266
+ * @param {string} text
267
+ * @param {{text: string, index: number}[]} matches ordered by index, indices into `text`
268
+ * @param {(match: {text: string, index: number}, i: number) => string} replacementFor
269
+ * @returns {{text: string, spans: {start: number, end: number}[]}} spliced text
270
+ * and the [start, end) range each replacement occupies in it
271
+ */
272
+ export function spliceOrdered(text, matches, replacementFor) {
273
+ let out = "";
274
+ let last = 0;
275
+ /** @type {{start: number, end: number}[]} */
276
+ const spans = [];
277
+ matches.forEach((match, i) => {
278
+ if (match.index < last) return;
279
+ out += text.slice(last, match.index);
280
+ const start = out.length;
281
+ out += replacementFor(match, i);
282
+ spans.push({ start, end: out.length });
283
+ last = match.index + match.text.length;
284
+ });
285
+ return { text: out + text.slice(last), spans };
286
+ }
287
+
241
288
  /**
242
289
  * On-disk [start, end) span of every redaction pair, mapped from its view
243
290
  * offset through placeholder expansion (view → cleaned) and stripped invisible
@@ -308,24 +355,16 @@ export function rehydrateNewString(oldS, newS, spanPairs, filePairs) {
308
355
  newSeq.length === spanPairs.length &&
309
356
  newSeq.every((match, i) => match.text === spanPairs[i].placeholder)
310
357
  ) {
311
- let out = "";
312
- let last = 0;
313
- newSeq.forEach((match, i) => {
314
- out += newS.slice(last, match.index) + spanPairs[i].original;
315
- last = match.index + match.text.length;
316
- });
317
358
  return {
318
- text: out + newS.slice(last),
359
+ text: spliceOrdered(newS, newSeq, (_match, i) => spanPairs[i].original)
360
+ .text,
319
361
  secrets: spanPairs.map((pair) => pair.original),
320
362
  };
321
363
  }
322
364
 
323
365
  // Each placeholder text must name exactly one secret; resolve that mapping
324
- // first, then splice in a SINGLE ordered pass. A chained
325
- // `out.split(ph).join(secret)` per placeholder is unsound: an inserted secret
326
- // whose bytes happen to contain a later placeholder text would be re-matched
327
- // and corrupted (or partially exposed) by the next split. One pass over the
328
- // ordered match positions only ever touches the original new_string bytes.
366
+ // first, then splice in a SINGLE ordered pass (see spliceOrdered for why a
367
+ // chained `out.split(ph).join(secret)` per placeholder is unsound).
329
368
  const valueByPh = new Map();
330
369
  for (const phText of new Set(newSeq.map((match) => match.text))) {
331
370
  const values = [
@@ -344,11 +383,9 @@ export function rehydrateNewString(oldS, newS, spanPairs, filePairs) {
344
383
  };
345
384
  valueByPh.set(phText, values[0]);
346
385
  }
347
- let out = "";
348
- let last = 0;
349
- for (const match of newSeq) {
350
- out += newS.slice(last, match.index) + valueByPh.get(match.text);
351
- last = match.index + match.text.length;
352
- }
353
- return { text: out + newS.slice(last), secrets: [...valueByPh.values()] };
386
+ return {
387
+ text: spliceOrdered(newS, newSeq, (match) => valueByPh.get(match.text))
388
+ .text,
389
+ secrets: [...valueByPh.values()],
390
+ };
354
391
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tokenize every raw control introducer in `text`.
3
+ *
4
+ * Every introducer yields exactly one token — an ORPHAN when it starts nothing
5
+ * the grammar recognizes — so "which introducers are in this text" and "which
6
+ * sequences are in this text" are answered by the same scan. That is what lets
7
+ * the stripper (splice every non-orphan token, then sweep) and the SGR-only
8
+ * predicate (every token is SGR) agree by construction.
9
+ *
10
+ * Tokens are disjoint and ordered by `start`; each `end` is strictly greater
11
+ * than its `start`, so the scan always advances.
12
+ * @param {string} text
13
+ * @returns {AnsiToken[]}
14
+ */
15
+ export function scanAnsi(text: string): AnsiToken[];
16
+ /**
17
+ * The ONE ANSI grammar: the raw control-introducer charset and the tokenizer
18
+ * every consumer scans with.
19
+ *
20
+ * Two modules need this grammar and they cannot import each other —
21
+ * `layer1.mjs` imports `invisible.mjs`, so `invisible.mjs` (which owns the
22
+ * public `isSgrOnly` / `SGR_RE`) must not import back. Before this module the
23
+ * grammar was therefore written out twice with DIFFERENT param rules
24
+ * (`invisible.mjs`'s SGR regex accepted any digit run, `layer1.mjs`'s CSI
25
+ * branch capped each parameter at four digits), and the introducer charset
26
+ * three times. The looser copy suppressed the operator warning for a sequence
27
+ * the stripper could not match: `ESC[12345m` read as "display-only colour"
28
+ * while `[12345m` was spliced into the model's view as visible text. One
29
+ * tokenizer, one charset, consumed by both — the disagreement cannot recur.
30
+ *
31
+ * Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
32
+ * module both layers read from.
33
+ */
34
+ export const CONTROL_INTRODUCER_SOURCE: "[\\u001b\\u0080-\\u009f]";
35
+ /**
36
+ * Public alias kept for compatibility (re-exported by `invisible.mjs` and the
37
+ * package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
38
+ * by testing the token's own text against this exact source, so the predicate
39
+ * and the regex can no longer describe different languages.
40
+ */
41
+ export const SGR_RE: RegExp;
42
+ /** The four things an introducer can turn out to be. */
43
+ export const TOKEN_KIND: Readonly<{
44
+ /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
45
+ SGR: "sgr";
46
+ /** Any other complete CSI / two-byte escape (cursor move, erase, charset). */
47
+ CSI: "csi";
48
+ /** An OSC string: introducer, body and terminator as one unit. */
49
+ OSC: "osc";
50
+ /** An introducer that starts no sequence the grammar recognizes. */
51
+ ORPHAN: "orphan-introducer";
52
+ }>;
53
+ export type AnsiToken = {
54
+ /**
55
+ * Index of the introducer.
56
+ */
57
+ start: number;
58
+ /**
59
+ * Index one past the last character of the token.
60
+ */
61
+ end: number;
62
+ /**
63
+ * One of {@link TOKEN_KIND}.
64
+ */
65
+ kind: string;
66
+ };