agent-sanitizer 2.34.4 → 2.34.6
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/claude-hooks/lib/authored-content.mjs +28 -3
- package/package.json +1 -1
- package/src/confusables.mjs +28 -10
- package/src/index.mjs +2 -0
- package/src/instructions.mjs +54 -28
- package/src/invisible.mjs +85 -5
- package/types/index.d.mts +1 -1
- package/types/invisible.d.mts +42 -0
|
@@ -53,11 +53,36 @@ import { lazyImport } from "./hook-io.mjs";
|
|
|
53
53
|
const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
54
54
|
await lazyImport("agent-sanitizer")
|
|
55
55
|
);
|
|
56
|
-
const { STRIP,
|
|
56
|
+
const { STRIP, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, stripInvisible } =
|
|
57
57
|
/** @type {typeof import("agent-sanitizer/invisible")} */ (
|
|
58
58
|
await lazyImport("agent-sanitizer/invisible")
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* "A run of {@link LONG_RUN_THRESHOLD} or more invisibles", bounded per match.
|
|
63
|
+
*
|
|
64
|
+
* Built from the engine's own class and threshold rather than imported as a
|
|
65
|
+
* ready-made pattern or scan function, because the bundle resolves
|
|
66
|
+
* `agent-sanitizer` to the PINNED published engine, which trails this repo:
|
|
67
|
+
* anything this hook imports has to exist in that pin, or the import binds
|
|
68
|
+
* undefined and the hook fails closed on every payload. STRIP and
|
|
69
|
+
* LONG_RUN_THRESHOLD are the primitives that define a long run, so deriving the
|
|
70
|
+
* pattern here keeps the answer identical to the engine's across pins, with no
|
|
71
|
+
* version-specific scan API to adopt when the pin moves.
|
|
72
|
+
*
|
|
73
|
+
* The upper bound is what makes it safe on a large payload: V8 pushes one
|
|
74
|
+
* backtrack entry per iteration of a quantifier onto a stack capped at 64 MB,
|
|
75
|
+
* so an UNBOUNDED run pattern throws `RangeError: Maximum call stack size
|
|
76
|
+
* exceeded` once a single run passes ~8.4 M code points — an 8 MB paste of
|
|
77
|
+
* zero-widths into a Write body is exactly that. A bound of 2^20 iterations
|
|
78
|
+
* sits ~8x under the ceiling, and a longer run still answers yes: any run of at
|
|
79
|
+
* least the threshold contains a prefix this matches.
|
|
80
|
+
*/
|
|
81
|
+
const LONG_RUN_CHUNK_RE = new RegExp(
|
|
82
|
+
`(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${1 << 20}}`,
|
|
83
|
+
"gu",
|
|
84
|
+
);
|
|
85
|
+
|
|
61
86
|
// Content fields the model authors, per tool. Paths and confusables are the
|
|
62
87
|
// confusable layer's domain; here we target the free-text fields that carry
|
|
63
88
|
// model-authored prose / code / data out into persisted or displayed artifacts.
|
|
@@ -140,8 +165,8 @@ export function authoredScopeDecision(tool) {
|
|
|
140
165
|
// user→model surfaces share one definition of "stego payload".
|
|
141
166
|
/** @param {string} text */
|
|
142
167
|
function isPayloadCapable(text) {
|
|
143
|
-
|
|
144
|
-
if (
|
|
168
|
+
LONG_RUN_CHUNK_RE.lastIndex = 0;
|
|
169
|
+
if (LONG_RUN_CHUNK_RE.test(text)) return true;
|
|
145
170
|
return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
|
|
146
171
|
}
|
|
147
172
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.34.
|
|
3
|
+
"version": "2.34.6",
|
|
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": {
|
package/src/confusables.mjs
CHANGED
|
@@ -281,20 +281,38 @@ export function selectFoldableFindings(text, findings) {
|
|
|
281
281
|
* @returns {string}
|
|
282
282
|
*/
|
|
283
283
|
export function foldConfusables(text, findings) {
|
|
284
|
-
|
|
284
|
+
// The folded text is `text.slice(0, cursor)` followed by `tail` read
|
|
285
|
+
// BACKWARDS: each finding appends the gap that follows it and then its
|
|
286
|
+
// replacement, so the string is assembled once at the end. Splicing a fresh
|
|
287
|
+
// string per finding instead costs O(findings x length) — a 128 KB command
|
|
288
|
+
// stuffed with look-alikes took 1.8 s of the PreToolUse hook that way.
|
|
289
|
+
/** @type {string[]} */
|
|
290
|
+
const tail = [];
|
|
291
|
+
let cursor = text.length;
|
|
292
|
+
const rebuild = () => [...tail].reverse().join("");
|
|
285
293
|
for (const finding of [...findings].sort(
|
|
286
294
|
(lhs, rhs) => rhs.index - lhs.index,
|
|
287
295
|
)) {
|
|
288
|
-
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
+
const end = finding.index + finding.char.length;
|
|
297
|
+
// Validate against the text as the fold has left it, so a scanner reporting
|
|
298
|
+
// a glyph that is not there fails loud. Highest-index-first leaves every
|
|
299
|
+
// offset below `cursor` byte-identical to `text`, so a finding ending there
|
|
300
|
+
// is checked against `text` itself; one reaching PAST `cursor` overlaps a
|
|
301
|
+
// fold already applied, and only the rebuilt tail carries the bytes it now
|
|
302
|
+
// sits on.
|
|
303
|
+
if (end <= cursor) {
|
|
304
|
+
assertFinding(text, finding);
|
|
305
|
+
tail.push(text.slice(end, cursor));
|
|
306
|
+
} else {
|
|
307
|
+
const folded = rebuild();
|
|
308
|
+
assertFinding(text.slice(0, cursor) + folded, finding);
|
|
309
|
+
tail.length = 0;
|
|
310
|
+
tail.push(folded.slice(end - cursor));
|
|
311
|
+
}
|
|
312
|
+
tail.push(finding.latinEquivalent);
|
|
313
|
+
cursor = finding.index;
|
|
296
314
|
}
|
|
297
|
-
return
|
|
315
|
+
return text.slice(0, cursor) + rebuild();
|
|
298
316
|
}
|
|
299
317
|
|
|
300
318
|
/**
|
package/src/index.mjs
CHANGED
package/src/instructions.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
import { randomBytes } from "node:crypto";
|
|
36
36
|
import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
|
|
37
37
|
import {
|
|
38
|
-
|
|
38
|
+
findLongRuns,
|
|
39
39
|
SCATTERED_THRESHOLD,
|
|
40
40
|
countPayloadInvisible,
|
|
41
41
|
stripInvisible,
|
|
@@ -59,6 +59,36 @@ export {
|
|
|
59
59
|
// instruction the model might follow.
|
|
60
60
|
const UNTRUSTED_PREFIX = "untrusted data, not instructions: ";
|
|
61
61
|
|
|
62
|
+
// U+000A — the separator a finding's line number counts.
|
|
63
|
+
const NEWLINE = 0x0a;
|
|
64
|
+
|
|
65
|
+
// Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
|
|
66
|
+
const ZW_BIT = new Map([
|
|
67
|
+
[0x200b, "0"],
|
|
68
|
+
[0x200c, "1"],
|
|
69
|
+
[0x200d, "|"],
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// How much of a zero-width-binary payload the report shows. The rest is
|
|
73
|
+
// summarized by the count beside it, so decoding past this is work nobody reads.
|
|
74
|
+
const BITS_SHOWN = 80;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The first {@link BITS_SHOWN} zero-width bits of a run, in report order.
|
|
78
|
+
* @param {number[]} cps
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function zeroWidthBits(cps) {
|
|
82
|
+
let bits = "";
|
|
83
|
+
for (const cp of cps) {
|
|
84
|
+
const bit = ZW_BIT.get(cp);
|
|
85
|
+
if (bit === undefined) continue;
|
|
86
|
+
bits += bit;
|
|
87
|
+
if (bits.length === BITS_SHOWN) break;
|
|
88
|
+
}
|
|
89
|
+
return bits;
|
|
90
|
+
}
|
|
91
|
+
|
|
62
92
|
/**
|
|
63
93
|
* Render decoded tag-character bytes as a NEUTRAL, quoted, escaped string so the
|
|
64
94
|
* scan report can never re-inject them. Only U+E0020–U+E007E decode to their
|
|
@@ -93,20 +123,15 @@ function neutralizeTagBytes(asciiCodes) {
|
|
|
93
123
|
* @returns {{ method: string, decoded: string }}
|
|
94
124
|
*/
|
|
95
125
|
export function decodeRun(run) {
|
|
96
|
-
|
|
126
|
+
/** @type {number[]} */
|
|
127
|
+
const cps = [];
|
|
128
|
+
for (const ch of run) cps.push(/** @type {number} */ (ch.codePointAt(0)));
|
|
97
129
|
|
|
98
130
|
// Tag characters U+E0001-U+E007F: raw ASCII byte is cp − 0xE0000 (0x01–0x7F).
|
|
99
131
|
const tagBytes = cps
|
|
100
132
|
.filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
|
|
101
133
|
.map((cp) => cp - 0xe0000);
|
|
102
134
|
|
|
103
|
-
// Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
|
|
104
|
-
const ZW_BIT = new Map([
|
|
105
|
-
[0x200b, "0"],
|
|
106
|
-
[0x200c, "1"],
|
|
107
|
-
[0x200d, "|"],
|
|
108
|
-
]);
|
|
109
|
-
|
|
110
135
|
const zwCount = cps.filter((cp) => ZW_BIT.has(cp)).length;
|
|
111
136
|
|
|
112
137
|
// Only take the tag-characters branch when tag chars are the MAJORITY of the
|
|
@@ -130,15 +155,11 @@ export function decodeRun(run) {
|
|
|
130
155
|
// the binary payload it actually is, not mislabeled). Decode only the ZW code
|
|
131
156
|
// points; a `+ N other char(s)` note keeps any non-ZW portion visible.
|
|
132
157
|
if (zwCount > 0 && zwCount > cps.length / 2) {
|
|
133
|
-
const bits = cps
|
|
134
|
-
.filter((cp) => ZW_BIT.has(cp))
|
|
135
|
-
.map((cp) => ZW_BIT.get(cp))
|
|
136
|
-
.join("");
|
|
137
158
|
const otherCount = cps.length - zwCount;
|
|
138
159
|
const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
|
|
139
160
|
return {
|
|
140
161
|
method: "zero-width binary encoding",
|
|
141
|
-
decoded: `[${zwCount} zero-width chars: ${
|
|
162
|
+
decoded: `[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]${note}`,
|
|
142
163
|
};
|
|
143
164
|
}
|
|
144
165
|
|
|
@@ -151,13 +172,8 @@ export function decodeRun(run) {
|
|
|
151
172
|
const parts = [];
|
|
152
173
|
if (tagBytes.length > 0)
|
|
153
174
|
parts.push(`${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"`);
|
|
154
|
-
if (zwCount > 0)
|
|
155
|
-
|
|
156
|
-
.filter((cp) => ZW_BIT.has(cp))
|
|
157
|
-
.map((cp) => ZW_BIT.get(cp))
|
|
158
|
-
.join("");
|
|
159
|
-
parts.push(`[${zwCount} zero-width chars: ${bits.slice(0, 80)}]`);
|
|
160
|
-
}
|
|
175
|
+
if (zwCount > 0)
|
|
176
|
+
parts.push(`[${zwCount} zero-width chars: ${zeroWidthBits(cps)}]`);
|
|
161
177
|
const otherCount = cps.length - tagBytes.length - zwCount;
|
|
162
178
|
const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
|
|
163
179
|
return {
|
|
@@ -186,14 +202,24 @@ export function decodeRun(run) {
|
|
|
186
202
|
*/
|
|
187
203
|
export function scanText(content) {
|
|
188
204
|
const findings = [];
|
|
189
|
-
LONG_RUN_RE.lastIndex = 0;
|
|
190
|
-
let match;
|
|
191
205
|
let runChars = 0;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
// The line number is carried forward across runs. Deriving it per run from
|
|
207
|
+
// the start of the file — `content.slice(0, run.index).split("\n")` — copies
|
|
208
|
+
// the whole prefix and materializes every line before the run, so a file
|
|
209
|
+
// carrying many runs pays that once per run: quadratic in the file length, on
|
|
210
|
+
// the SessionStart path the user waits for. Runs arrive in increasing index
|
|
211
|
+
// order, so this scan only ever moves forward.
|
|
212
|
+
let line = 1;
|
|
213
|
+
let counted = 0;
|
|
214
|
+
for (const run of findLongRuns(content)) {
|
|
215
|
+
for (; counted < run.index; counted++)
|
|
216
|
+
if (content.charCodeAt(counted) === NEWLINE) line++;
|
|
217
|
+
runChars += run.charCount;
|
|
218
|
+
findings.push({
|
|
219
|
+
line,
|
|
220
|
+
charCount: run.charCount,
|
|
221
|
+
...decodeRun(run.text),
|
|
222
|
+
});
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
// Threshold-evasion: scattered invisible chars not in a long run can still be
|
package/src/invisible.mjs
CHANGED
|
@@ -157,11 +157,93 @@ export const LONG_RUN_THRESHOLD = 10;
|
|
|
157
157
|
* payload-capable even without a long run (threshold-evasion catch). */
|
|
158
158
|
export const SCATTERED_THRESHOLD = 30;
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The long-run pattern, declaratively: {@link LONG_RUN_THRESHOLD} or more
|
|
162
|
+
* consecutive {@link STRIP} code points.
|
|
163
|
+
*
|
|
164
|
+
* Scan a document with {@link findLongRuns}, not with this: `exec`/`test`
|
|
165
|
+
* throw `RangeError: Maximum call stack size exceeded` once a run passes
|
|
166
|
+
* ~8.4 M code points, because V8 pushes one backtrack entry per iteration of
|
|
167
|
+
* an unbounded quantifier onto a stack capped at 64 MB. This stays public as
|
|
168
|
+
* the pattern itself, and as the independent oracle the scan is differenced
|
|
169
|
+
* against (test/invisible-fast-path.test.mjs).
|
|
170
|
+
*/
|
|
160
171
|
export const LONG_RUN_RE = new RegExp(
|
|
161
172
|
`(?:${STRIP.source}){${LONG_RUN_THRESHOLD},}`,
|
|
162
173
|
REGEX_FLAGS,
|
|
163
174
|
);
|
|
164
175
|
|
|
176
|
+
// Iterations per `exec` below, which is what bounds the backtrack stack each
|
|
177
|
+
// one needs: a match can push at most this many entries, ~8x under the ceiling
|
|
178
|
+
// an unbounded quantifier walks into on an 8 MB payload. Runs longer than this
|
|
179
|
+
// are stitched from consecutive matches, so the bound costs an extra `exec`
|
|
180
|
+
// per megabyte of PAYLOAD and nothing at all on ordinary text.
|
|
181
|
+
const RUN_CHUNK = 1 << 20;
|
|
182
|
+
|
|
183
|
+
const LONG_RUN_CHUNK_RE = new RegExp(
|
|
184
|
+
`(?:${STRIP.source}){${LONG_RUN_THRESHOLD},${RUN_CHUNK}}`,
|
|
185
|
+
REGEX_FLAGS,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// The same class, sticky and from one repetition, to carry a run past the chunk
|
|
189
|
+
// bound: anchored at the end of the previous match, it either extends the run
|
|
190
|
+
// or fails immediately.
|
|
191
|
+
const RUN_TAIL_RE = new RegExp(`(?:${STRIP.source}){1,${RUN_CHUNK}}`, "yu");
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every maximal run of at least {@link LONG_RUN_THRESHOLD} consecutive
|
|
195
|
+
* payload-capable invisible code points in `text`, in order: `index` is the
|
|
196
|
+
* run's UTF-16 offset, `text` its verbatim slice, `charCount` its length in
|
|
197
|
+
* code points.
|
|
198
|
+
*
|
|
199
|
+
* What {@link LONG_RUN_RE} means, in the form every scanner in this package
|
|
200
|
+
* uses — because that regex cannot answer for a large document, and an 8 MB
|
|
201
|
+
* paste of zero-widths (the exact payload the scan exists to catch) is what
|
|
202
|
+
* took out the SessionStart scanner, the prompt gate and the tool-output tier
|
|
203
|
+
* alike. Bounding the quantifier bounds the backtrack stack per `exec`; a run
|
|
204
|
+
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
205
|
+
* runs reported are maximal at any length.
|
|
206
|
+
* @param {string} text
|
|
207
|
+
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
208
|
+
*/
|
|
209
|
+
export function* findLongRuns(text) {
|
|
210
|
+
// Both regexes are module-level and carry `lastIndex`, and a generator can be
|
|
211
|
+
// suspended anywhere — including inside another scan of another text. Every
|
|
212
|
+
// exec below therefore sets its own start position first, so no scan can
|
|
213
|
+
// inherit a position from one it interleaved with.
|
|
214
|
+
let pos = 0;
|
|
215
|
+
for (;;) {
|
|
216
|
+
LONG_RUN_CHUNK_RE.lastIndex = pos;
|
|
217
|
+
const match = LONG_RUN_CHUNK_RE.exec(text);
|
|
218
|
+
if (match === null) return;
|
|
219
|
+
let end = LONG_RUN_CHUNK_RE.lastIndex;
|
|
220
|
+
// A run shorter than the bound fails this on the first try, for the cost of
|
|
221
|
+
// one anchored no-match.
|
|
222
|
+
for (;;) {
|
|
223
|
+
RUN_TAIL_RE.lastIndex = end;
|
|
224
|
+
if (RUN_TAIL_RE.exec(text) === null) break;
|
|
225
|
+
end = RUN_TAIL_RE.lastIndex;
|
|
226
|
+
}
|
|
227
|
+
const run = text.slice(match.index, end);
|
|
228
|
+
yield { index: match.index, text: run, charCount: codePointLength(run) };
|
|
229
|
+
pos = end;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True when `text` carries at least one {@link findLongRuns} run.
|
|
235
|
+
*
|
|
236
|
+
* The bounded pattern answers this on its own: a run long enough to be reported
|
|
237
|
+
* is long enough to match, whether or not the match reaches the run's end — so
|
|
238
|
+
* the yes/no costs one anchored scan and never measures the run.
|
|
239
|
+
* @param {string} text
|
|
240
|
+
* @returns {boolean}
|
|
241
|
+
*/
|
|
242
|
+
export function hasLongRun(text) {
|
|
243
|
+
LONG_RUN_CHUNK_RE.lastIndex = 0;
|
|
244
|
+
return LONG_RUN_CHUNK_RE.test(text);
|
|
245
|
+
}
|
|
246
|
+
|
|
165
247
|
/**
|
|
166
248
|
* The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
|
|
167
249
|
* labels, the LONG RUN marker when the de-ANSI'd text still holds a
|
|
@@ -1150,12 +1232,10 @@ export function payloadLongRunSample(text) {
|
|
|
1150
1232
|
// The view is code-point-for-code-point with `text` and only ever REPLACES an
|
|
1151
1233
|
// invisible with a space, so a run in the view is a run in `text`: no long run
|
|
1152
1234
|
// in the raw text means none in the view. This hides no payload — the bulk
|
|
1153
|
-
//
|
|
1235
|
+
// scan reads the whole text, and a run it finds still goes through the full
|
|
1154
1236
|
// carve analysis below to decide what of it is really payload.
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
LONG_RUN_RE.lastIndex = 0;
|
|
1158
|
-
return payloadInvisibleView(text).match(LONG_RUN_RE)?.[0] ?? null;
|
|
1237
|
+
if (!hasLongRun(text)) return null;
|
|
1238
|
+
return findLongRuns(payloadInvisibleView(text)).next().value?.text ?? null;
|
|
1159
1239
|
}
|
|
1160
1240
|
|
|
1161
1241
|
/**
|
package/types/index.d.mts
CHANGED
|
@@ -53,5 +53,5 @@ export function sanitize(text: string, options?: {
|
|
|
53
53
|
}>;
|
|
54
54
|
}>;
|
|
55
55
|
export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
|
|
56
|
-
export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
|
|
56
|
+
export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD, findLongRuns, hasLongRun } from "./invisible.mjs";
|
|
57
57
|
export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint } from "./gates.mjs";
|
package/types/invisible.d.mts
CHANGED
|
@@ -14,6 +14,37 @@
|
|
|
14
14
|
* @returns {boolean}
|
|
15
15
|
*/
|
|
16
16
|
export function isSgrOnly(text: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Every maximal run of at least {@link LONG_RUN_THRESHOLD} consecutive
|
|
19
|
+
* payload-capable invisible code points in `text`, in order: `index` is the
|
|
20
|
+
* run's UTF-16 offset, `text` its verbatim slice, `charCount` its length in
|
|
21
|
+
* code points.
|
|
22
|
+
*
|
|
23
|
+
* What {@link LONG_RUN_RE} means, in the form every scanner in this package
|
|
24
|
+
* uses — because that regex cannot answer for a large document, and an 8 MB
|
|
25
|
+
* paste of zero-widths (the exact payload the scan exists to catch) is what
|
|
26
|
+
* took out the SessionStart scanner, the prompt gate and the tool-output tier
|
|
27
|
+
* alike. Bounding the quantifier bounds the backtrack stack per `exec`; a run
|
|
28
|
+
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
|
+
* runs reported are maximal at any length.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
|
+
*/
|
|
33
|
+
export function findLongRuns(text: string): Generator<{
|
|
34
|
+
index: number;
|
|
35
|
+
text: string;
|
|
36
|
+
charCount: number;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* True when `text` carries at least one {@link findLongRuns} run.
|
|
40
|
+
*
|
|
41
|
+
* The bounded pattern answers this on its own: a run long enough to be reported
|
|
42
|
+
* is long enough to match, whether or not the match reaches the run's end — so
|
|
43
|
+
* the yes/no costs one anchored scan and never measures the run.
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function hasLongRun(text: string): boolean;
|
|
17
48
|
/**
|
|
18
49
|
* The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
|
|
19
50
|
* labels, the LONG RUN marker when the de-ANSI'd text still holds a
|
|
@@ -162,6 +193,17 @@ export const LONG_RUN_THRESHOLD: 10;
|
|
|
162
193
|
/** Total invisible-char count above which a file/prompt is treated as
|
|
163
194
|
* payload-capable even without a long run (threshold-evasion catch). */
|
|
164
195
|
export const SCATTERED_THRESHOLD: 30;
|
|
196
|
+
/**
|
|
197
|
+
* The long-run pattern, declaratively: {@link LONG_RUN_THRESHOLD} or more
|
|
198
|
+
* consecutive {@link STRIP} code points.
|
|
199
|
+
*
|
|
200
|
+
* Scan a document with {@link findLongRuns}, not with this: `exec`/`test`
|
|
201
|
+
* throw `RangeError: Maximum call stack size exceeded` once a run passes
|
|
202
|
+
* ~8.4 M code points, because V8 pushes one backtrack entry per iteration of
|
|
203
|
+
* an unbounded quantifier onto a stack capped at 64 MB. This stays public as
|
|
204
|
+
* the pattern itself, and as the independent oracle the scan is differenced
|
|
205
|
+
* against (test/invisible-fast-path.test.mjs).
|
|
206
|
+
*/
|
|
165
207
|
export const LONG_RUN_RE: RegExp;
|
|
166
208
|
export const CONSECUTIVE_JOINER_CAP: 8;
|
|
167
209
|
export const CONSECUTIVE_SELECTOR_CAP: 8;
|