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/README.md +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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
|
|
210
|
-
*
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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)
|
|
419
|
-
//
|
|
420
|
-
//
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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.
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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:
|
|
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
|
|
325
|
-
// `out.split(ph).join(secret)` per placeholder is unsound
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
}
|
package/types/ansi.d.mts
ADDED
|
@@ -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
|
+
};
|