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/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
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
|
+
}
|