agent-sanitizer 2.0.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/layer1.mjs ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Layer 1: ANSI + invisible-character stripping. The zero-dependency core shared
3
+ * by the convenience `sanitize` (index.mjs), the tool-output pipeline
4
+ * (output.mjs), and the Edit-repair rehydrator (rehydrate.mjs) — a single
5
+ * implementation so every consumer derives the EXACT view the model was shown (a
6
+ * re-implementation would drift, and rehydration's soundness gate depends on
7
+ * re-cleaning reproducing the view).
8
+ *
9
+ * Lone-surrogate normalization is NOT applied by {@link applyLayer1} itself: it
10
+ * is exported as {@link LONE_SURROGATE_RE} for consumers to apply at the boundary
11
+ * that needs a well-formed string (the redactor input in output.mjs's
12
+ * processLayer1 / re-redact, and before the HTML tokenizer), because that is the
13
+ * point where a lone surrogate would otherwise corrupt a match or a parse.
14
+ */
15
+ import { stripInvisibleWithReport, CATEGORY } from "./invisible.mjs";
16
+
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;
28
+
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");
108
+
109
+ // Unpaired UTF-16 surrogates (high not followed by low, or low not preceded by
110
+ // high). Normalized before any HTML parser, which throws on a stray byte —
111
+ // which would otherwise let a single malformed code unit suppress all output.
112
+ export const LONE_SURROGATE_RE =
113
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
114
+
115
+ /**
116
+ * Strip ANSI escape sequences to a fixed point. Removing one sequence can
117
+ * reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
118
+ * trailing `[0m` once the inner sequence is removed, forming a brand-new valid
119
+ * sequence the single pass would miss), so iterate until stable: every changed
120
+ * pass consumes at least one ESC introducer, so the pass count is bounded by
121
+ * the input's ESC count, and ANSI-free text exits after one pass.
122
+ * @param {string} input
123
+ * @returns {string}
124
+ */
125
+ export function stripAnsiFully(input) {
126
+ let prev = input;
127
+ let out = prev.replace(ANSI_RE, "");
128
+ while (out !== prev) {
129
+ prev = out;
130
+ out = prev.replace(ANSI_RE, "");
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Layer 1: ANSI + invisible-char strip with a result guaranteed free of every
137
+ * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
138
+ * block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
139
+ *
140
+ * Removing an invisible character can reconstitute an escape its split hid from
141
+ * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
142
+ * invisible pass — but only when stripInvisible changed something, since
143
+ * reconstitution is impossible otherwise and the re-strip is a wasted pass on
144
+ * the hot clean path. The ANSI strip still cannot match an *incomplete*
145
+ * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
146
+ * removed from a nested split), so a final sweep removes every residual raw
147
+ * introducer outright — that sweep, not the regex matching, is the guarantee
148
+ * that no control introducer survives. `deAnsi` is the ANSI strip of the
149
+ * original (invisible runs intact), the scope a LONG_RUN payload check needs.
150
+ * @param {string} text
151
+ * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
152
+ */
153
+ export function applyLayer1(text) {
154
+ const deAnsi = stripAnsiFully(text);
155
+ // stripInvisibleWithReport returns `found` for exactly the categories it
156
+ // removed — so a ZWNJ/ZWJ the carve-out PRESERVES never registers as a strip,
157
+ // and the leading-BOM exception is already handled inside it. Pass the ORIGINAL
158
+ // `text` so a BOM that was interior before the ANSI strip (`ESC[m + interior U+FEFF`, now at
159
+ // index 0 of `deAnsi`) is treated as interior and stripped, not preserved.
160
+ const { cleaned: afterInvis, found } = stripInvisibleWithReport(deAnsi, text);
161
+ let ansiFound = deAnsi.length !== text.length;
162
+
163
+ let cleaned = afterInvis;
164
+ if (afterInvis !== deAnsi) {
165
+ const reStripped = stripAnsiFully(afterInvis);
166
+ if (reStripped.length !== afterInvis.length) ansiFound = true;
167
+ cleaned = reStripped;
168
+ }
169
+ const swept = cleaned.replace(CONTROL_INTRODUCER_RE, "");
170
+ if (swept !== cleaned) {
171
+ cleaned = swept;
172
+ ansiFound = true;
173
+ }
174
+
175
+ if (ansiFound) found.push(CATEGORY.ANSI);
176
+ return { cleaned, deAnsi, found };
177
+ }