agent-sanitizer 2.62.0 → 2.62.2
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/THREAT-MODEL.md +14 -2
- package/claude-hooks/lib/knobs.mjs +1 -0
- package/claude-hooks/lib/redactor-client.mjs +9 -5
- package/package.json +1 -1
- package/src/claude-context.mjs +4 -2
- package/src/glob-compat.mjs +84 -0
- package/src/instructions.mjs +42 -11
- package/src/invisible.mjs +32 -17
- package/types/claude-hooks/lib/knobs.d.mts +3 -0
- package/types/glob-compat.d.mts +28 -0
- package/types/invisible.d.mts +4 -1
- package/types/src/glob-compat.d.mts +28 -0
- package/types/src/invisible.d.mts +4 -1
package/THREAT-MODEL.md
CHANGED
|
@@ -823,7 +823,11 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
823
823
|
merge and that refresh, the default branch pins binaries compiled from the
|
|
824
824
|
previous bundle. The provisioner compares its own bundle against the manifest's
|
|
825
825
|
`# bundle=` pin and refuses on a mismatch, so the window degrades to the node
|
|
826
|
-
path rather than running a binary that is not the code the install ships.
|
|
826
|
+
path rather than running a binary that is not the code the install ships. A
|
|
827
|
+
binary provisioned before the window stays installed but loses its install
|
|
828
|
+
stamp, and the launcher runs a binary whose stamp does not name the shipped
|
|
829
|
+
manifest only on a host where no node is found — there an older sanitizer
|
|
830
|
+
still beats an unsanitized call.
|
|
827
831
|
- **Not covered: an attacker who can write BOTH the repository and the release.**
|
|
828
832
|
There is no signature; the manifest is trusted because it arrives through the
|
|
829
833
|
same reviewed, gated path as the rest of the plugin. Rewriting it is rewriting
|
|
@@ -842,11 +846,19 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
842
846
|
per install instead of once per tool call. V8 does not treat a code cache as
|
|
843
847
|
untrusted input, so those files are code the session runs, exactly like the
|
|
844
848
|
binary above. The launcher creates the directory mode 700 and gates it with
|
|
845
|
-
`
|
|
849
|
+
`trusted_dir` — the same owner-only-write property, refused with a line on
|
|
846
850
|
stderr when the directory does not hold it — so the same user-owned data
|
|
847
851
|
directory is the assumption here too. It carries no digest and needs none:
|
|
848
852
|
anything able to write there can already run code per the bullet above. Nothing
|
|
849
853
|
is fetched for it, so it adds no supply-chain surface.
|
|
854
|
+
- **The same gate covers everything else the launcher takes from a shared
|
|
855
|
+
location.** The provisioned redactor venv (`${CLAUDE_PLUGIN_DATA}/venv` and its
|
|
856
|
+
`bin/`, whose daemon receives every secret-bearing payload) is handed to the
|
|
857
|
+
client only when `trusted_dir` vouches for both directories; otherwise the
|
|
858
|
+
client redacts through the committed zipapp. The degraded-warning marker root
|
|
859
|
+
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
860
|
+
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
861
|
+
unsanitized.
|
|
850
862
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
851
863
|
a binary. That leaves the node path running the committed bundle and the
|
|
852
864
|
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
@@ -220,6 +220,7 @@ export const KNOBS = Object.freeze({
|
|
|
220
220
|
_AGENT_SANITIZER_TRACE_FILE: on("internal", text()),
|
|
221
221
|
_AGENT_SANITIZER_REDACTOR_SOCKET: on("internal", text()),
|
|
222
222
|
_AGENT_SANITIZER_REDACTOR_DAEMON: on("internal", text()),
|
|
223
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: on("internal", text()),
|
|
223
224
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: on("internal", milliseconds(8000)),
|
|
224
225
|
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: on("internal", milliseconds(20000)),
|
|
225
226
|
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: on("internal", milliseconds(120000)),
|
|
@@ -60,10 +60,12 @@ export const DEFAULT_SOCKET_PATH =
|
|
|
60
60
|
* sets it to the provisioned venv's console script when one exists, and
|
|
61
61
|
* tests point it at dead paths to drive the fail-closed arm; a fallback
|
|
62
62
|
* from an explicit setting would turn those arms into silent passes).
|
|
63
|
-
* 2. The committed self-contained zipapp
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
63
|
+
* 2. The committed self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
|
|
64
|
+
* the guaranteed floor: it needs only a python3 on PATH, so a cold start
|
|
65
|
+
* with no venv and no network still redacts instead of failing closed on
|
|
66
|
+
* every tool call. At `_AGENT_SANITIZER_REDACTOR_PYZ` when the launcher
|
|
67
|
+
* exports it (the compiled binary's module-relative path names no file on
|
|
68
|
+
* disk), else beside this bundle.
|
|
67
69
|
* 3. The packaged console script from PATH — the un-bundled/dev case, where
|
|
68
70
|
* no sibling dist tree exists.
|
|
69
71
|
* @returns {string[]}
|
|
@@ -74,7 +76,9 @@ function daemonCommand() {
|
|
|
74
76
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
75
77
|
// so the committed zipapp sits one level up; from the raw sources no such
|
|
76
78
|
// sibling exists and the check falls through.
|
|
77
|
-
const pyz =
|
|
79
|
+
const pyz =
|
|
80
|
+
readKnob("_AGENT_SANITIZER_REDACTOR_PYZ") ||
|
|
81
|
+
fileURLToPath(new URL("../redactor/daemon.pyz", import.meta.url));
|
|
78
82
|
if (existsSync(pyz)) return ["python3", pyz];
|
|
79
83
|
return ["agent-secret-redactor-daemon"];
|
|
80
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.62.
|
|
3
|
+
"version": "2.62.2",
|
|
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/claude-context.mjs
CHANGED
|
@@ -225,9 +225,11 @@ export function isInsideDir(dir, file) {
|
|
|
225
225
|
|
|
226
226
|
// What rendered untrusted text must never carry raw: what Layer 1 strips, C0/C1
|
|
227
227
|
// controls (ESC opens a terminal escape, a newline forges a report line),
|
|
228
|
-
// line/paragraph separators, lone surrogates.
|
|
228
|
+
// line/paragraph separators, lone surrogates. Spelled as ranges, not runtime
|
|
229
|
+
// `\p{}` lookups: Cc and Cs are immutable under Unicode's stability policy, and
|
|
230
|
+
// Zl/Zp are the single code points U+2028/U+2029.
|
|
229
231
|
const DISPLAY_UNSAFE = new RegExp(
|
|
230
|
-
`${STRIP.source}|[\\
|
|
232
|
+
`${STRIP.source}|[\\u0000-\\u001F\\u007F-\\u009F\\u2028\\u2029\\uD800-\\uDFFF]`,
|
|
231
233
|
"gu",
|
|
232
234
|
);
|
|
233
235
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob patterns rewritten for the Node releases whose `fs.globSync` cannot see
|
|
3
|
+
* a dot-named segment after a `**`.
|
|
4
|
+
*
|
|
5
|
+
* Before 22.22.1, 24.13.1 and 25.4.0, and on every 23.x, globSync matches
|
|
6
|
+
* NOTHING for a `**` directly followed by a dot-named segment
|
|
7
|
+
* (nodejs/node#61012): `**\/.claude/*.md` finds no file, so an instruction scan
|
|
8
|
+
* reports clean over files it never read. On those releases each such pattern
|
|
9
|
+
* is rewritten into forms the broken walker answers; on every other release
|
|
10
|
+
* patterns pass through untouched, so a fixed Node keeps its own semantics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// The first release of each affected line that carries the fix. 23.x never got
|
|
14
|
+
// one; every line from 26 on was cut after it landed.
|
|
15
|
+
const FIXED_IN = new Map([
|
|
16
|
+
[22, [22, 1]],
|
|
17
|
+
[24, [13, 1]],
|
|
18
|
+
[25, [4, 0]],
|
|
19
|
+
]);
|
|
20
|
+
const FIRST_FIXED_MAJOR = 26;
|
|
21
|
+
|
|
22
|
+
// A `**` segment directly followed by a dot-named one, at the pattern start or
|
|
23
|
+
// after a `/`, or opening a brace alternative.
|
|
24
|
+
const DOT_AFTER_GLOBSTAR = /(?:^|[/{,])\*\*\/(?=\.)/;
|
|
25
|
+
|
|
26
|
+
// A dot segment after `**/` that the rewrite cannot reach: escaped, or opening
|
|
27
|
+
// an alternative of a brace set.
|
|
28
|
+
const HIDDEN_DOT_AFTER_GLOBSTAR = /\*\*\/(?:\\\.|\{(?:[^{}]*,)?\.)/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether the globSync of Node `version` (`process.versions.node`'s shape)
|
|
32
|
+
* misses a dot-named segment after a `**`.
|
|
33
|
+
* @param {string} version
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function globstarSkipsDotSegments(version) {
|
|
37
|
+
const [major, minor, patch] = version.split(".").map(Number);
|
|
38
|
+
if (major >= FIRST_FIXED_MAJOR) return false;
|
|
39
|
+
const fixed = FIXED_IN.get(major);
|
|
40
|
+
if (!fixed) return true;
|
|
41
|
+
const [fixedMinor, fixedPatch] = fixed;
|
|
42
|
+
return minor < fixedMinor || (minor === fixedMinor && patch < fixedPatch);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `pattern` as patterns the broken globSync answers.
|
|
47
|
+
*
|
|
48
|
+
* Each `**\/` before a dot segment spans zero directories or one or more:
|
|
49
|
+
* dropping it is the first case and `**\/*\/` the second, and neither puts a
|
|
50
|
+
* dot segment straight after a `**`. A `*` never matches a dot-named directory,
|
|
51
|
+
* so the halves can match less than a fixed Node does (which enters a dot
|
|
52
|
+
* directory named like the segment after `**`), never more. A dot segment the
|
|
53
|
+
* rewrite cannot reach throws rather than silently matching nothing.
|
|
54
|
+
* @param {string} pattern
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
export function expandDotGlobstar(pattern) {
|
|
58
|
+
const match = DOT_AFTER_GLOBSTAR.exec(pattern);
|
|
59
|
+
if (match) {
|
|
60
|
+
const head = pattern.slice(0, match.index + match[0].indexOf("*"));
|
|
61
|
+
const tail = pattern.slice(match.index + match[0].length);
|
|
62
|
+
return [`${head}${tail}`, `${head}**/*/${tail}`].flatMap(expandDotGlobstar);
|
|
63
|
+
}
|
|
64
|
+
if (HIDDEN_DOT_AFTER_GLOBSTAR.test(pattern))
|
|
65
|
+
throw new Error(
|
|
66
|
+
`glob ${JSON.stringify(pattern)} puts a dot-named segment after "**" in a ` +
|
|
67
|
+
`form this Node's globSync matches nothing for (nodejs/node#61012); ` +
|
|
68
|
+
`spell the segment plainly or run Node >= 22.22.1, 24.13.1 or 25.4.0.`,
|
|
69
|
+
);
|
|
70
|
+
return [pattern];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `patterns` ready for this runtime's globSync: rewritten by
|
|
75
|
+
* {@link expandDotGlobstar} on an affected Node, untouched otherwise.
|
|
76
|
+
* @param {string[]} patterns
|
|
77
|
+
* @param {string} [version] defaults to the running Node
|
|
78
|
+
* @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
export function compatGlobs(patterns, version = process.versions.node) {
|
|
81
|
+
return globstarSkipsDotSegments(version)
|
|
82
|
+
? patterns.flatMap(expandDotGlobstar)
|
|
83
|
+
: patterns;
|
|
84
|
+
}
|
package/src/instructions.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
stripInvisible,
|
|
41
41
|
} from "./invisible.mjs";
|
|
42
42
|
import { excludeNodeModules, isInsideDir } from "./claude-context.mjs";
|
|
43
|
+
import { compatGlobs } from "./glob-compat.mjs";
|
|
43
44
|
|
|
44
45
|
// The library's public door onto Claude Code's context scope. The definitions
|
|
45
46
|
// live in a dependency-free data module because the SessionStart hook imports
|
|
@@ -231,11 +232,20 @@ export function scanText(content) {
|
|
|
231
232
|
let line = 1;
|
|
232
233
|
let counted = 0;
|
|
233
234
|
// The verdict keeps only the first run; the rest come off the payload view,
|
|
234
|
-
// which is index-aligned with `content` and masks preserved invisibles.
|
|
235
|
+
// which is index-aligned with `content` and masks preserved invisibles. The
|
|
236
|
+
// search resumes past the first run rather than matching it a second time,
|
|
237
|
+
// which on one 256 KB payload run is a regex pass over the whole file.
|
|
238
|
+
const { firstRun } = verdict;
|
|
235
239
|
const runs =
|
|
236
|
-
|
|
240
|
+
firstRun === null
|
|
237
241
|
? []
|
|
238
|
-
:
|
|
242
|
+
: [
|
|
243
|
+
firstRun,
|
|
244
|
+
...findLongRuns(
|
|
245
|
+
payloadInvisibleView(content),
|
|
246
|
+
firstRun.index + firstRun.text.length,
|
|
247
|
+
),
|
|
248
|
+
];
|
|
239
249
|
for (const run of runs) {
|
|
240
250
|
for (; counted < run.index; counted++)
|
|
241
251
|
if (content.charCodeAt(counted) === NEWLINE) line++;
|
|
@@ -377,14 +387,35 @@ export function walkContextGlobs(globs, cwd, exclude) {
|
|
|
377
387
|
// under a `.claude/worktrees/` checkout.
|
|
378
388
|
/** @param {import("node:fs").Dirent} dirent @returns {string} */
|
|
379
389
|
const absolute = (dirent) => resolve(root, dirent.parentPath, dirent.name);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
390
|
+
/** @param {string} entry @returns {boolean} */
|
|
391
|
+
const excluded = (entry) =>
|
|
392
|
+
excludeNodeModules(entry) || (exclude?.(entry) ?? false);
|
|
393
|
+
return (
|
|
394
|
+
globSync(compatGlobs(globs), {
|
|
395
|
+
cwd,
|
|
396
|
+
withFileTypes: true,
|
|
397
|
+
exclude: (dirent) =>
|
|
398
|
+
excluded(relative(root, absolute(dirent)).split(sep).join("/")),
|
|
399
|
+
})
|
|
400
|
+
.map(absolute)
|
|
401
|
+
// Node never consults `exclude` on some literal segments (the `.claude`
|
|
402
|
+
// compatGlobs spells out on an affected Node is one), so a match under a
|
|
403
|
+
// directory the caller pruned is dropped here instead.
|
|
404
|
+
.filter((path) => !ancestorEntries(relative(root, path)).some(excluded))
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Every directory above `rel` (a root-relative path), as `/`-separated
|
|
410
|
+
* entries outermost first: `a/b/c.md` gives `a` and `a/b`.
|
|
411
|
+
* @param {string} rel
|
|
412
|
+
* @returns {string[]}
|
|
413
|
+
*/
|
|
414
|
+
function ancestorEntries(rel) {
|
|
415
|
+
const segments = rel.split(sep);
|
|
416
|
+
return segments
|
|
417
|
+
.slice(0, -1)
|
|
418
|
+
.map((_, i) => segments.slice(0, i + 1).join("/"));
|
|
388
419
|
}
|
|
389
420
|
|
|
390
421
|
/**
|
package/src/invisible.mjs
CHANGED
|
@@ -218,14 +218,16 @@ const RUN_TAIL_RE = new RegExp(`(?:${STRIP.source}){1,${RUN_CHUNK}}`, "yu");
|
|
|
218
218
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
219
219
|
* runs reported are maximal at any length.
|
|
220
220
|
* @param {string} text
|
|
221
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
222
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
221
223
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
222
224
|
*/
|
|
223
|
-
export function* findLongRuns(text) {
|
|
225
|
+
export function* findLongRuns(text, from = 0) {
|
|
224
226
|
// Both regexes are module-level and carry `lastIndex`, and a generator can be
|
|
225
227
|
// suspended anywhere — including inside another scan of another text. Every
|
|
226
228
|
// exec below therefore sets its own start position first, so no scan can
|
|
227
229
|
// inherit a position from one it interleaved with.
|
|
228
|
-
let pos =
|
|
230
|
+
let pos = from;
|
|
229
231
|
for (;;) {
|
|
230
232
|
LONG_RUN_CHUNK_RE.lastIndex = pos;
|
|
231
233
|
const match = LONG_RUN_CHUNK_RE.exec(text);
|
|
@@ -1231,12 +1233,11 @@ function markTagSequences(cps) {
|
|
|
1231
1233
|
return keep;
|
|
1232
1234
|
}
|
|
1233
1235
|
|
|
1234
|
-
// The
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1236
|
+
// The selectors the registered sequences use: a cheap pre-filter. Not their
|
|
1237
|
+
// span — U+180B to U+FE0D also holds U+200B and most other Cf code points, so a
|
|
1238
|
+
// zero-width run paid the keyed sequence lookup once per character.
|
|
1239
|
+
const STANDARDIZED_SELECTORS = new Set(
|
|
1240
|
+
STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1240
1241
|
);
|
|
1241
1242
|
|
|
1242
1243
|
/**
|
|
@@ -1249,8 +1250,7 @@ const STANDARDIZED_SELECTOR_MAX = Math.max(
|
|
|
1249
1250
|
*/
|
|
1250
1251
|
function isStandardizedVariationSelector(cps, i) {
|
|
1251
1252
|
const cp = cps[i];
|
|
1252
|
-
if (cp
|
|
1253
|
-
return false;
|
|
1253
|
+
if (!STANDARDIZED_SELECTORS.has(cp)) return false;
|
|
1254
1254
|
return i > 0 && isStandardizedVariant(cps[i - 1], cp);
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
@@ -1892,7 +1892,7 @@ function carveWalk(body, cps, analysis, build) {
|
|
|
1892
1892
|
* @typedef {{ body: string, cps: Int32Array,
|
|
1893
1893
|
* analysis: ReturnType<typeof analyzeCarve>,
|
|
1894
1894
|
* strip: { cleaned: string, found: string[], removed: number } | null,
|
|
1895
|
-
* removed: number | null }} CarveEntry
|
|
1895
|
+
* removed: number | null, view: string | null }} CarveEntry
|
|
1896
1896
|
*/
|
|
1897
1897
|
|
|
1898
1898
|
/** @type {CarveEntry | null} */
|
|
@@ -1900,9 +1900,9 @@ let lastCarve = null;
|
|
|
1900
1900
|
|
|
1901
1901
|
/**
|
|
1902
1902
|
* The carve analysis of `body`, with the most recent one remembered — and its
|
|
1903
|
-
* strip, or
|
|
1904
|
-
* document several times (the payload view, the effective count,
|
|
1905
|
-
* {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1903
|
+
* strip, removal count or payload view, once asked for. One hook call often
|
|
1904
|
+
* carves one document several times (the payload view, the effective count,
|
|
1905
|
+
* then {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1906
1906
|
* so the cache changes what a verdict costs, never the verdict. One entry,
|
|
1907
1907
|
* recorded only after the analysis returns.
|
|
1908
1908
|
* @param {string} body
|
|
@@ -1912,7 +1912,7 @@ function carveOf(body) {
|
|
|
1912
1912
|
if (lastCarve !== null && lastCarve.body === body) return lastCarve;
|
|
1913
1913
|
const cps = codePointArray(body);
|
|
1914
1914
|
const analysis = analyzeCarve(cps);
|
|
1915
|
-
lastCarve = { body, cps, analysis, strip: null, removed: null };
|
|
1915
|
+
lastCarve = { body, cps, analysis, strip: null, removed: null, view: null };
|
|
1916
1916
|
return lastCarve;
|
|
1917
1917
|
}
|
|
1918
1918
|
|
|
@@ -1952,10 +1952,13 @@ function needsCarveOut(body) {
|
|
|
1952
1952
|
* @returns {string}
|
|
1953
1953
|
*/
|
|
1954
1954
|
export function payloadInvisibleView(text) {
|
|
1955
|
+
const entry = carveOf(text);
|
|
1956
|
+
// The verdict's run search and a caller listing every run both read it.
|
|
1957
|
+
if (entry.view !== null) return entry.view;
|
|
1955
1958
|
const {
|
|
1956
1959
|
cps,
|
|
1957
1960
|
analysis: { codes, kind },
|
|
1958
|
-
} =
|
|
1961
|
+
} = entry;
|
|
1959
1962
|
const offsets = u16Offsets(cps);
|
|
1960
1963
|
/** @param {number} i */
|
|
1961
1964
|
const isPayload = (i) => codes[i] !== CODE_VISIBLE && kind[i] === KIND_NONE;
|
|
@@ -1974,7 +1977,8 @@ export function payloadInvisibleView(text) {
|
|
|
1974
1977
|
while (i < cps.length && isPayload(i)) i++;
|
|
1975
1978
|
if (i > keepFrom) parts.push(text.slice(offsets[keepFrom], offsets[i]));
|
|
1976
1979
|
}
|
|
1977
|
-
|
|
1980
|
+
entry.view = parts.join("");
|
|
1981
|
+
return entry.view;
|
|
1978
1982
|
}
|
|
1979
1983
|
|
|
1980
1984
|
/**
|
|
@@ -2065,6 +2069,17 @@ export function countEffectiveInvisible(text) {
|
|
|
2065
2069
|
// Otherwise both terms read ONE carve analysis (see carveOf).
|
|
2066
2070
|
const entry = carveOf(text);
|
|
2067
2071
|
const payload = entry.analysis.payloadInvis;
|
|
2072
|
+
// When every invisible is payload, nothing is preservable, so either strip
|
|
2073
|
+
// removes exactly the invisibles and the surplus is zero — unless a removal
|
|
2074
|
+
// closes a lone high surrogate against a lone low one, which the code-point
|
|
2075
|
+
// gap counts too (see carveWalk's `removed`) and which needs a lone low one.
|
|
2076
|
+
// A hidden run or a scatter of soft hyphens lands here, and answering off the
|
|
2077
|
+
// analysis spares it a whole-text strip it would only measure.
|
|
2078
|
+
if (
|
|
2079
|
+
payload === entry.analysis.invisible.length &&
|
|
2080
|
+
!LONE_LOW_SURROGATE_RE.test(text)
|
|
2081
|
+
)
|
|
2082
|
+
return payload;
|
|
2068
2083
|
if (!needsCarveOut(text))
|
|
2069
2084
|
return surplusOver(
|
|
2070
2085
|
payload,
|
|
@@ -93,6 +93,9 @@ export const KNOBS: Readonly<{
|
|
|
93
93
|
_AGENT_SANITIZER_REDACTOR_DAEMON: Readonly<Knob<string | undefined> & {
|
|
94
94
|
surface: Surface;
|
|
95
95
|
}>;
|
|
96
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: Readonly<Knob<string | undefined> & {
|
|
97
|
+
surface: Surface;
|
|
98
|
+
}>;
|
|
96
99
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: Readonly<Knob<number> & {
|
|
97
100
|
surface: Surface;
|
|
98
101
|
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the globSync of Node `version` (`process.versions.node`'s shape)
|
|
3
|
+
* misses a dot-named segment after a `**`.
|
|
4
|
+
* @param {string} version
|
|
5
|
+
* @returns {boolean}
|
|
6
|
+
*/
|
|
7
|
+
export function globstarSkipsDotSegments(version: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* `pattern` as patterns the broken globSync answers.
|
|
10
|
+
*
|
|
11
|
+
* Each `**\/` before a dot segment spans zero directories or one or more:
|
|
12
|
+
* dropping it is the first case and `**\/*\/` the second, and neither puts a
|
|
13
|
+
* dot segment straight after a `**`. A `*` never matches a dot-named directory,
|
|
14
|
+
* so the halves can match less than a fixed Node does (which enters a dot
|
|
15
|
+
* directory named like the segment after `**`), never more. A dot segment the
|
|
16
|
+
* rewrite cannot reach throws rather than silently matching nothing.
|
|
17
|
+
* @param {string} pattern
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
export function expandDotGlobstar(pattern: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* `patterns` ready for this runtime's globSync: rewritten by
|
|
23
|
+
* {@link expandDotGlobstar} on an affected Node, untouched otherwise.
|
|
24
|
+
* @param {string[]} patterns
|
|
25
|
+
* @param {string} [version] defaults to the running Node
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function compatGlobs(patterns: string[], version?: string): string[];
|
package/types/invisible.d.mts
CHANGED
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the globSync of Node `version` (`process.versions.node`'s shape)
|
|
3
|
+
* misses a dot-named segment after a `**`.
|
|
4
|
+
* @param {string} version
|
|
5
|
+
* @returns {boolean}
|
|
6
|
+
*/
|
|
7
|
+
export function globstarSkipsDotSegments(version: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* `pattern` as patterns the broken globSync answers.
|
|
10
|
+
*
|
|
11
|
+
* Each `**\/` before a dot segment spans zero directories or one or more:
|
|
12
|
+
* dropping it is the first case and `**\/*\/` the second, and neither puts a
|
|
13
|
+
* dot segment straight after a `**`. A `*` never matches a dot-named directory,
|
|
14
|
+
* so the halves can match less than a fixed Node does (which enters a dot
|
|
15
|
+
* directory named like the segment after `**`), never more. A dot segment the
|
|
16
|
+
* rewrite cannot reach throws rather than silently matching nothing.
|
|
17
|
+
* @param {string} pattern
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
export function expandDotGlobstar(pattern: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* `patterns` ready for this runtime's globSync: rewritten by
|
|
23
|
+
* {@link expandDotGlobstar} on an affected Node, untouched otherwise.
|
|
24
|
+
* @param {string[]} patterns
|
|
25
|
+
* @param {string} [version] defaults to the running Node
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function compatGlobs(patterns: string[], version?: string): string[];
|
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|