agent-sanitizer 2.57.4 → 2.58.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/README.md +11 -1
- package/THREAT-MODEL.md +18 -0
- package/bin/sanitize-cli.mjs +14 -3
- package/claude-hooks/lib/invisible-alert.mjs +18 -13
- package/package.json +1 -1
- package/src/claude-context.mjs +10 -6
- package/src/instructions.mjs +47 -16
- package/src/repo-scope.mjs +240 -0
- package/types/claude-context.d.mts +9 -5
- package/types/instructions.d.mts +24 -5
- package/types/repo-scope.d.mts +58 -0
- package/types/src/ansi.d.mts +176 -0
- package/types/src/cf-charset.d.mts +14 -0
- package/types/src/claude-context.d.mts +9 -5
- package/types/src/instructions.d.mts +170 -0
- package/types/src/invisible.d.mts +217 -0
- package/types/src/joining-type.d.mts +32 -0
- package/types/src/repo-scope.d.mts +58 -0
- package/types/src/standardized-variants.d.mts +22 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The linked worktrees in a `git worktree list --porcelain -z` dump, main and
|
|
3
|
+
* bare ones excluded — those are not removable, so their state is never at risk
|
|
4
|
+
* from a teardown command, and the main one is the scan root a prune must never
|
|
5
|
+
* swallow.
|
|
6
|
+
* @param {string} porcelain
|
|
7
|
+
* @returns {string[]} absolute worktree paths
|
|
8
|
+
*/
|
|
9
|
+
export function parseWorktreeList(porcelain: string): string[];
|
|
10
|
+
/**
|
|
11
|
+
* The linked worktrees of the repo at `cwd`. The guard on `git worktree remove`
|
|
12
|
+
* asks this the same way the prune below does, so one parser answers for both.
|
|
13
|
+
* @param {string} cwd
|
|
14
|
+
* @param {GitRun} run
|
|
15
|
+
* @returns {string[]} absolute worktree paths
|
|
16
|
+
*/
|
|
17
|
+
export function linkedWorktrees(cwd: string, run: GitRun): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Directory paths a context scan of `dir` must not walk, relative to it and
|
|
20
|
+
* `/`-separated: every wholly-ignored directory (unless `ignoredDirs` is off)
|
|
21
|
+
* and every linked worktree nested inside it.
|
|
22
|
+
* @param {string} dir
|
|
23
|
+
* @param {{ ignoredDirs?: boolean, run?: GitRun }} [options]
|
|
24
|
+
* @returns {Set<string>}
|
|
25
|
+
*/
|
|
26
|
+
export function repoPrunedDirs(dir: string, { ignoredDirs, run }?: {
|
|
27
|
+
ignoredDirs?: boolean;
|
|
28
|
+
run?: GitRun;
|
|
29
|
+
}): Set<string>;
|
|
30
|
+
/**
|
|
31
|
+
* The `exclude` predicate for an instruction-file walk of `dir`: the static
|
|
32
|
+
* context-scope prune, plus {@link repoPrunedDirs}.
|
|
33
|
+
*
|
|
34
|
+
* `ignoredDirs: false` is the LAUNCH scan's posture, and it is a security
|
|
35
|
+
* choice rather than a performance one: `.gitignore` is repo-controlled, so
|
|
36
|
+
* honouring it in the one scan that covers launch-time ingress would let a
|
|
37
|
+
* hostile repo hide a planted `.claude/skills/…/SKILL.md` from it by ignoring
|
|
38
|
+
* that directory. The whole-tree scan can honour it because anything it prunes
|
|
39
|
+
* is still scanned by scan-loaded-instructions at the moment the host loads it.
|
|
40
|
+
*
|
|
41
|
+
* The lookup is EXACT, so the walk must hand it the same spelling
|
|
42
|
+
* {@link repoPrunedDirs} uses — one root-relative, `/`-separated path per entry,
|
|
43
|
+
* which is what walkContextGlobs normalizes to. A predicate that accepted a bare
|
|
44
|
+
* name as well would prune a tracked `src/build/` for a top-level ignored
|
|
45
|
+
* `build/`, splicing real instruction files out of the scan.
|
|
46
|
+
* @param {string} dir
|
|
47
|
+
* @param {{ ignoredDirs?: boolean, run?: GitRun }} [options]
|
|
48
|
+
* @returns {(entry: string) => boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function contextScanExclude(dir: string, options?: {
|
|
51
|
+
ignoredDirs?: boolean;
|
|
52
|
+
run?: GitRun;
|
|
53
|
+
}): (entry: string) => boolean;
|
|
54
|
+
/**
|
|
55
|
+
* How git is asked. Injectable so a test can drive the failure paths, which no
|
|
56
|
+
* filesystem state can force.
|
|
57
|
+
*/
|
|
58
|
+
export type GitRun = (file: string, args: string[], cwd: string) => string;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True for either orphan kind — the tokens {@link scanAnsi} emits for an
|
|
3
|
+
* introducer that completes no sequence, which the stripper must leave in place
|
|
4
|
+
* for the residual sweep rather than splice (see stripAnsiOnce).
|
|
5
|
+
* @param {string} kind one of {@link TOKEN_KIND}
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isOrphanKind(kind: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The orphan kind for the introducer character `ch`, given the character `next`
|
|
11
|
+
* that follows it: a raw C1 byte, an `ESC` that opened an incomplete CSI, or a
|
|
12
|
+
* lone `ESC`. The one place that split is decided, shared by the tokenizer and
|
|
13
|
+
* by Layer 1's residual sweep (which sees bare characters, not tokens).
|
|
14
|
+
*
|
|
15
|
+
* `[` is the only lookahead that matters. The five string introducers — `ESC ]`,
|
|
16
|
+
* `ESC P`, `ESC X`, `ESC ^`, `ESC _` — are consumed whole by
|
|
17
|
+
* {@link scanControlString}, to the end of input if unterminated, so they never
|
|
18
|
+
* reach here; every other second byte (`ESC (`, `ESC #`) bounds what a terminal
|
|
19
|
+
* swallows to a byte or two rather than running until a final byte arrives.
|
|
20
|
+
* @param {string} ch
|
|
21
|
+
* @param {string} [next] the following character, or undefined at end of input
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function orphanKindFor(ch: string, next?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Tokenize every raw control introducer in `text`.
|
|
27
|
+
*
|
|
28
|
+
* Every introducer yields exactly one token — an orphan kind (see
|
|
29
|
+
* {@link orphanKindFor}) when it starts nothing the grammar recognizes — so
|
|
30
|
+
* "which introducers are in this text" and "which
|
|
31
|
+
* sequences are in this text" are answered by the same scan. That is what lets
|
|
32
|
+
* the stripper (splice every non-orphan token, then sweep) and the SGR-only
|
|
33
|
+
* predicate (every token is SGR) agree by construction.
|
|
34
|
+
*
|
|
35
|
+
* Tokens are disjoint and ordered by `start`; each `end` is strictly greater
|
|
36
|
+
* than its `start`, so the scan always advances.
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @returns {AnsiToken[]}
|
|
39
|
+
*/
|
|
40
|
+
export function scanAnsi(text: string): AnsiToken[];
|
|
41
|
+
/**
|
|
42
|
+
* The CONCEAL state after the SGR token `token` is applied to a terminal already
|
|
43
|
+
* in state `concealed` — the state in which a terminal renders what follows as
|
|
44
|
+
* blank while its bytes stay readable to anything reading the file.
|
|
45
|
+
*
|
|
46
|
+
* A TRANSITION, not a property of the token: conceal is terminal state that
|
|
47
|
+
* outlives the sequence that set it, so `ESC[8m` followed by `ESC[31m` is still
|
|
48
|
+
* concealed — a per-token predicate would read the second token as "no conceal
|
|
49
|
+
* here" and lose the state. Only `8` (set), `28` (reveal) and `0` (reset all)
|
|
50
|
+
* move it, and the token's parameters are applied in order, so a later reveal or
|
|
51
|
+
* reset in the SAME token cancels an earlier `8`: `ESC[8;28;31m` renders red and
|
|
52
|
+
* visible.
|
|
53
|
+
*
|
|
54
|
+
* The parameters are read the way a terminal reads them rather than scanned for
|
|
55
|
+
* the digit. `38`/`48`/`58` take their colour arguments from the parameters that
|
|
56
|
+
* FOLLOW them in the semicolon form, so `ESC[38;5;8m` is bright-black foreground
|
|
57
|
+
* and its `8` is a palette index; a parameter carrying its arguments as ITU T.416
|
|
58
|
+
* sub-parameters (`38:5:8`) is self-contained, so only its head counts and
|
|
59
|
+
* nothing after it is consumed. A colour-space selector T.416 gives no argument
|
|
60
|
+
* count for is malformed, and a terminal that ignores the colour form still
|
|
61
|
+
* applies what follows it, so the scan CONTINUES from the next parameter rather
|
|
62
|
+
* than consuming arguments it cannot size — reading one parameter too many costs
|
|
63
|
+
* a token shape no emitter produces, while stopping there would hand
|
|
64
|
+
* `ESC[38;9;8m` a pass.
|
|
65
|
+
* @param {string} token a token {@link scanAnsi} classified {@link TOKEN_KIND.SGR}
|
|
66
|
+
* @param {boolean} concealed the state before this token
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function sgrConcealState(token: string, concealed: boolean): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The ONE ANSI grammar: the raw control-introducer charset and the tokenizer
|
|
72
|
+
* every consumer scans with.
|
|
73
|
+
*
|
|
74
|
+
* Two modules need this grammar and they cannot import each other —
|
|
75
|
+
* `layer1.mjs` imports `invisible.mjs`, so `invisible.mjs` (which owns the
|
|
76
|
+
* public `isSgrOnly` / `SGR_RE`) must not import back. Before this module the
|
|
77
|
+
* grammar was therefore written out twice with DIFFERENT param rules
|
|
78
|
+
* (`invisible.mjs`'s SGR regex accepted any digit run, `layer1.mjs`'s CSI
|
|
79
|
+
* branch capped each parameter at four digits), and the introducer charset
|
|
80
|
+
* three times. The looser copy suppressed the operator warning for a sequence
|
|
81
|
+
* the stripper could not match: `ESC[12345m` read as "display-only colour"
|
|
82
|
+
* while `[12345m` was spliced into the model's view as visible text. One
|
|
83
|
+
* tokenizer, one charset, consumed by both — the disagreement cannot recur.
|
|
84
|
+
*
|
|
85
|
+
* Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
|
|
86
|
+
* module both layers read from.
|
|
87
|
+
*/
|
|
88
|
+
export const CONTROL_INTRODUCER_CODEPOINTS: readonly number[];
|
|
89
|
+
export const CONTROL_INTRODUCER_SOURCE: string;
|
|
90
|
+
/**
|
|
91
|
+
* Public alias kept for compatibility (re-exported by `invisible.mjs` and the
|
|
92
|
+
* package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
|
|
93
|
+
* by testing the token's own text against this exact source, so the predicate
|
|
94
|
+
* and the regex can no longer describe different languages.
|
|
95
|
+
*/
|
|
96
|
+
export const SGR_RE: RegExp;
|
|
97
|
+
/**
|
|
98
|
+
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
99
|
+
* artifact for a consumer that cannot run this module.
|
|
100
|
+
*
|
|
101
|
+
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
102
|
+
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
103
|
+
* it is linear by construction where the regex form has to carry an explicit
|
|
104
|
+
* guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
|
|
105
|
+
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
106
|
+
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
107
|
+
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
108
|
+
* set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
|
|
109
|
+
* hand-written spellings of one grammar.
|
|
110
|
+
*
|
|
111
|
+
* Every construct here is common to JS and Python `re` with NO flags —
|
|
112
|
+
* `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
|
|
113
|
+
* `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
|
|
114
|
+
* — so ONE pattern string is what both engines read.
|
|
115
|
+
* `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
|
|
116
|
+
* corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
|
|
117
|
+
*/
|
|
118
|
+
export const ESCAPE_SEQUENCE_SOURCE: string;
|
|
119
|
+
/** The seven things an introducer can turn out to be. */
|
|
120
|
+
export const TOKEN_KIND: Readonly<{
|
|
121
|
+
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
122
|
+
SGR: "sgr";
|
|
123
|
+
/** Any other complete CSI / two-byte escape (cursor move, erase, charset). */
|
|
124
|
+
CSI: "csi";
|
|
125
|
+
/** An OSC string: introducer, body and terminator as one unit. */
|
|
126
|
+
OSC: "osc";
|
|
127
|
+
/**
|
|
128
|
+
* One of the other four ECMA-48 control strings — DCS, SOS, PM or APC —
|
|
129
|
+
* introducer, body and terminator as one unit, exactly like
|
|
130
|
+
* {@link TOKEN_KIND.OSC}. Split from it only so a warning can name what it
|
|
131
|
+
* found; both are payload-carrying strings and neither is benign.
|
|
132
|
+
*/
|
|
133
|
+
CONTROL_STRING: "control-string";
|
|
134
|
+
/**
|
|
135
|
+
* A 7-bit `ESC` that starts no sequence the grammar recognizes — a truncated
|
|
136
|
+
* write, a log fragment cut mid-escape, a stray byte living in a file.
|
|
137
|
+
*/
|
|
138
|
+
ORPHAN: "orphan-introducer";
|
|
139
|
+
/**
|
|
140
|
+
* A 7-bit `ESC` that OPENS a CSI (`ESC [`) it never completes. Split from
|
|
141
|
+
* {@link TOKEN_KIND.ORPHAN} because a terminal's CSI parser is STATEFUL: it
|
|
142
|
+
* keeps consuming what follows as parameters and intermediates until a final
|
|
143
|
+
* byte (0x40-0x7E) arrives, so `hello ESC[12 world` renders as `hello orld`
|
|
144
|
+
* — the ` w` is eaten as the sequence's intermediate and final. That is the
|
|
145
|
+
* model-sees/human-sees divergence the gate exists for, so consumers that
|
|
146
|
+
* downgrade an inert strip to a note must keep warning on this one; only a
|
|
147
|
+
* lone `ESC` that opens nothing is inert.
|
|
148
|
+
*/
|
|
149
|
+
ORPHAN_CSI: "orphan-csi-introducer";
|
|
150
|
+
/**
|
|
151
|
+
* A RAW C1 byte (U+0080-U+009F) that starts no sequence the grammar
|
|
152
|
+
* recognizes. Split from {@link TOKEN_KIND.ORPHAN} because the two carry very
|
|
153
|
+
* different weight: a lone `ESC` is ordinary debris in terminal output, while
|
|
154
|
+
* a raw C1 byte is not something legitimate UTF-8 text produces. The five
|
|
155
|
+
* string introducers in the block open a {@link TOKEN_KIND.OSC} or
|
|
156
|
+
* {@link TOKEN_KIND.CONTROL_STRING} token instead, so a byte that reaches
|
|
157
|
+
* here is one the grammar recognizes no sequence for at all — and a terminal
|
|
158
|
+
* may still act on it. Consumers that downgrade an inert strip to a note (see
|
|
159
|
+
* `isBenignAnsiKinds` in ./layer1.mjs) must keep warning on this one.
|
|
160
|
+
*/
|
|
161
|
+
ORPHAN_C1: "orphan-c1-introducer";
|
|
162
|
+
}>;
|
|
163
|
+
export type AnsiToken = {
|
|
164
|
+
/**
|
|
165
|
+
* Index of the introducer.
|
|
166
|
+
*/
|
|
167
|
+
start: number;
|
|
168
|
+
/**
|
|
169
|
+
* Index one past the last character of the token.
|
|
170
|
+
*/
|
|
171
|
+
end: number;
|
|
172
|
+
/**
|
|
173
|
+
* One of {@link TOKEN_KIND}.
|
|
174
|
+
*/
|
|
175
|
+
kind: string;
|
|
176
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by scripts/gen-invisible-charset.mjs from Node's Unicode data
|
|
3
|
+
* (\p{Cf}, Unicode 17.0) — DO NOT EDIT.
|
|
4
|
+
*
|
|
5
|
+
* The general-category Cf code points, PINNED at generation time. invisible.mjs
|
|
6
|
+
* strips exactly this set instead of testing \p{Cf} live, and the Python port
|
|
7
|
+
* reads the SAME set from data/invisible-charset.json's `cf_codepoints`, so both
|
|
8
|
+
* layers strip an identical Cf set regardless of each runtime's own Unicode
|
|
9
|
+
* version. Regenerate with `node scripts/gen-invisible-charset.mjs`;
|
|
10
|
+
* test/invisible-charset.test.mjs fails if this drifts from Node's \p{Cf}.
|
|
11
|
+
*/
|
|
12
|
+
export const UNICODE_VERSION: "17.0";
|
|
13
|
+
/** @type {readonly number[]} Sorted ascending. */
|
|
14
|
+
export const CF_CODEPOINTS: readonly number[];
|
|
@@ -33,7 +33,11 @@ export function isInsideDir(dir: string, file: string): boolean;
|
|
|
33
33
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
34
34
|
* function so the name is spelled once, and so the two predicates that need it
|
|
35
35
|
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
36
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* The LAST segment is what it reads: a dependency tree nested under a workspace
|
|
38
|
+
* package is the same dependency tree, and an entry naming one arrives as the
|
|
39
|
+
* path `packages/a/node_modules`, never as a bare name.
|
|
40
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
37
41
|
* @returns {boolean}
|
|
38
42
|
*/
|
|
39
43
|
export function excludeNodeModules(entry: string): boolean;
|
|
@@ -49,10 +53,10 @@ export function excludeNodeModules(entry: string): boolean;
|
|
|
49
53
|
* context: a doubled-star segment does cross into a dot directory when the
|
|
50
54
|
* pattern names one.
|
|
51
55
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @param {string} entry a
|
|
56
|
+
* Entries are paths relative to the scan root, so a top-level one is a bare
|
|
57
|
+
* name: it carries no `.claude` context and is judged only against
|
|
58
|
+
* `node_modules`.
|
|
59
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
56
60
|
* @returns {boolean}
|
|
57
61
|
*/
|
|
58
62
|
export function excludeFromContextScan(entry: string): boolean;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode a run of invisible characters to its likely payload. Recognizes the
|
|
3
|
+
* two common smuggling encodings — Unicode tag characters (U+E0001–U+E007F map
|
|
4
|
+
* directly to ASCII) and zero-width binary (ZWSP=0, ZWNJ=1, ZWJ=separator) —
|
|
5
|
+
* and otherwise reports the raw code points. The tag-character payload is
|
|
6
|
+
* rendered as a neutral, quoted/escaped `untrusted data, not instructions: "…"`
|
|
7
|
+
* string (see {@link neutralizeTagBytes}) so the report can never re-inject the
|
|
8
|
+
* hidden instruction, and only U+E0020–U+E007E map to raw printable ASCII.
|
|
9
|
+
* @param {string} run
|
|
10
|
+
* @returns {{ method: string, decoded: string }}
|
|
11
|
+
*/
|
|
12
|
+
export function decodeRun(run: string): {
|
|
13
|
+
method: string;
|
|
14
|
+
decoded: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Scan a file's text for hidden-Unicode injection. Reports each long invisible
|
|
18
|
+
* run (with its decoded payload) plus a single scattered-chars finding when the
|
|
19
|
+
* non-run invisible count crosses the threshold-evasion floor.
|
|
20
|
+
* @param {string} content
|
|
21
|
+
* @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>}
|
|
22
|
+
* `line` is the 1-based line of a long-run finding, or `null` for the
|
|
23
|
+
* whole-file scattered-chars finding (not tied to a single line).
|
|
24
|
+
*/
|
|
25
|
+
export function scanText(content: string): Array<{
|
|
26
|
+
line: number | null;
|
|
27
|
+
charCount: number;
|
|
28
|
+
method: string;
|
|
29
|
+
decoded: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Every `globs` match under `cwd`, as an absolute path, with `node_modules` and
|
|
33
|
+
* whatever `exclude` rejects pruned from the WALK rather than filtered from its
|
|
34
|
+
* results — which is where a wide glob's cost actually is.
|
|
35
|
+
*
|
|
36
|
+
* `withFileTypes` is what makes the prune ANSWERABLE. Without it the walker
|
|
37
|
+
* calls `exclude` once with an entry's bare name and again with its
|
|
38
|
+
* root-relative path, so a predicate holding `build` cannot tell a top-level
|
|
39
|
+
* `build/` from a tracked `src/build/` and prunes both; under an absolute
|
|
40
|
+
* pattern it is handed absolute paths and matches neither. A Dirent carries an
|
|
41
|
+
* absolute `parentPath`, which normalizes to exactly one root-relative,
|
|
42
|
+
* `/`-separated entry per walked directory whatever shape the pattern has.
|
|
43
|
+
* @param {string[]} globs
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @param {(entry: string) => boolean} [exclude]
|
|
46
|
+
* @returns {string[]} absolute paths, one match per element
|
|
47
|
+
*/
|
|
48
|
+
export function walkContextGlobs(globs: string[], cwd: string, exclude?: (entry: string) => boolean): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Expand `globs` (relative to `cwd`) to absolute file paths, skipping
|
|
51
|
+
* `node_modules`. The glob set is the caller's instruction-file convention.
|
|
52
|
+
*
|
|
53
|
+
* Containment is enforced per match (see {@link keepContained}): a match whose
|
|
54
|
+
* glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
|
|
55
|
+
* outside the tree — THROWS, since reaching outside the tree is a caller
|
|
56
|
+
* misconfiguration. A match that lexically stays inside `cwd` but resolves
|
|
57
|
+
* (via an in-tree symlink) to a target outside the tree, or that simply
|
|
58
|
+
* cannot be resolved (a dangling symlink or unreadable entry inside the
|
|
59
|
+
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
60
|
+
* project.
|
|
61
|
+
*
|
|
62
|
+
* `exclude` prunes the walk via {@link walkContextGlobs}, so it is handed one
|
|
63
|
+
* root-relative, `/`-separated path per entry. It is composed with, never
|
|
64
|
+
* replaces, the unconditional `node_modules` prune: a caller narrowing the scan
|
|
65
|
+
* must not be able to widen it into a dependency tree. Pass
|
|
66
|
+
* {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
67
|
+
* @param {string[]} globs
|
|
68
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function findInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
72
|
+
cwd?: string;
|
|
73
|
+
exclude?: (entry: string) => boolean;
|
|
74
|
+
}): string[];
|
|
75
|
+
/**
|
|
76
|
+
* Scan every instruction file matched by `globs` and return only those with
|
|
77
|
+
* findings, each path reported relative to `cwd`. Unreadable/missing files are
|
|
78
|
+
* skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
|
|
79
|
+
* @param {string[]} globs
|
|
80
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
81
|
+
* `exclude` is forwarded to {@link findInstructionFiles}
|
|
82
|
+
* @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
|
|
83
|
+
*/
|
|
84
|
+
export function scanInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
85
|
+
cwd?: string;
|
|
86
|
+
exclude?: (entry: string) => boolean;
|
|
87
|
+
}): Array<{
|
|
88
|
+
file: string;
|
|
89
|
+
findings: ReturnType<typeof scanText>;
|
|
90
|
+
}>;
|
|
91
|
+
/**
|
|
92
|
+
* Atomically replace `absPath`'s contents with `data`, preserving `mode`.
|
|
93
|
+
*
|
|
94
|
+
* Writes to a sibling temp in the same directory, then `rename`s it over the
|
|
95
|
+
* original (same dir => same filesystem => the rename is atomic, not a
|
|
96
|
+
* cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to
|
|
97
|
+
* crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the
|
|
98
|
+
* path already exists — including an attacker-planted symlink at a guessable
|
|
99
|
+
* temp name — the open fails (EEXIST) and does NOT follow the link to clobber
|
|
100
|
+
* its target. On the rare collision we fail loud rather than retry into a
|
|
101
|
+
* different attacker-controlled path.
|
|
102
|
+
*
|
|
103
|
+
* Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the
|
|
104
|
+
* rename and the directory fd is `fsync`ed after it, so a power loss can't leave
|
|
105
|
+
* the renamed name pointing at unflushed/empty data or lose the rename itself.
|
|
106
|
+
* The EXACT `mode` is applied with `fchmod` (openSync's create mode is
|
|
107
|
+
* umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s
|
|
108
|
+
* the temp before rethrowing so no partial temp leaks. `tmpName` and `remove`
|
|
109
|
+
* are injectable fault-injection seams for tests (force a known temp path; drive
|
|
110
|
+
* a cleanup-unlink failure); production callers never pass them.
|
|
111
|
+
* @param {string} absPath
|
|
112
|
+
* @param {string} data
|
|
113
|
+
* @param {number} mode
|
|
114
|
+
* @param {() => string} [tmpName]
|
|
115
|
+
* @param {(path: string) => void} [remove]
|
|
116
|
+
*/
|
|
117
|
+
export function atomicReplaceFile(absPath: string, data: string, mode: number, tmpName?: () => string, remove?: (path: string) => void): void;
|
|
118
|
+
/**
|
|
119
|
+
* Strip payload-capable invisible characters from `absPath` in place. Returns
|
|
120
|
+
* `true` when the file's bytes actually changed (a payload {@link scanText}
|
|
121
|
+
* flags was removed) and `false` when {@link scanText} reports nothing to
|
|
122
|
+
* strip. `true` means and only means "bytes changed", so a caller that flagged
|
|
123
|
+
* this file and gets `false` back must NOT record it as cleaned — the file
|
|
124
|
+
* changed under it, or the flagged run is one {@link stripInvisible}
|
|
125
|
+
* preserves (a well-formed emoji-tag sequence), and either way the payload it
|
|
126
|
+
* flagged is still there. There is no third return value: the `null` arm this
|
|
127
|
+
* doc once described was dropped as dead (`stripInvisible` cannot leave the
|
|
128
|
+
* bytes identical for anything `scanText` flags), and callers must branch on
|
|
129
|
+
* the boolean rather than testing against `null`, which is vacuously true.
|
|
130
|
+
*
|
|
131
|
+
* Contract (scan/clean coherence): clean strips exactly what scan flags. A
|
|
132
|
+
* write happens ONLY when `scanText` reports a finding, so the "scan, then
|
|
133
|
+
* clean what scan flagged" workflow never silently rewrites a file scan called
|
|
134
|
+
* clean. A handful of sub-threshold invisible chars (which scan ignores) are
|
|
135
|
+
* left untouched — by design, the scanner's definition of a payload is the
|
|
136
|
+
* single source of truth for what gets removed.
|
|
137
|
+
*
|
|
138
|
+
* Refuses to follow symlinks: instruction files must be regular files. The read
|
|
139
|
+
* fd is opened with `O_NOFOLLOW`, so a symlinked path (which could redirect the
|
|
140
|
+
* read/write to a target outside the tree) makes the OPEN itself fail — closing
|
|
141
|
+
* the lstat→open TOCTOU window a separate stat would leave, in which the path
|
|
142
|
+
* could be swapped to a symlink between the check and the read.
|
|
143
|
+
*
|
|
144
|
+
* Non-UTF-8 safety (O9): the file is read as raw BYTES and required to round-trip
|
|
145
|
+
* losslessly through UTF-8 before any rewrite. `readFileSync(…, "utf-8")`
|
|
146
|
+
* silently maps invalid bytes to U+FFFD, which a naive strip-and-rewrite would
|
|
147
|
+
* then persist file-wide — so a non-UTF-8 file fails loud and is left untouched.
|
|
148
|
+
*
|
|
149
|
+
* Lost-update / TOCTOU guard: the on-path file is re-checked against the fstat
|
|
150
|
+
* snapshot taken right after open (inode, size, mtime, and not-a-symlink) before
|
|
151
|
+
* the rename; a concurrent write or symlink swap between our read and our write
|
|
152
|
+
* fails loud rather than silently clobbering the other writer.
|
|
153
|
+
*
|
|
154
|
+
* The write is atomic (see {@link atomicReplaceFile}): stripped content goes to
|
|
155
|
+
* a temp file in the same directory which is then `rename`d over the original
|
|
156
|
+
* (preserving the original file mode), fsync'd for crash-safety.
|
|
157
|
+
*
|
|
158
|
+
* Throws if the file cannot be read or written (the caller decides whether an
|
|
159
|
+
* unwritable contaminated file is fatal or falls back to alerting).
|
|
160
|
+
* @param {string} absPath
|
|
161
|
+
* @param {(path: string) => import("node:fs").Stats} [lstat] injectable
|
|
162
|
+
* pre-rename recheck stat (fault-injection seam, mirrors
|
|
163
|
+
* {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent
|
|
164
|
+
* write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise
|
|
165
|
+
* unreachable from this fully-synchronous path. Defaults to `lstatSync`.
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
|
|
169
|
+
export { contextScanExclude } from "./repo-scope.mjs";
|
|
170
|
+
export { ancestorInstructionFiles, announcedByInstructionsLoaded, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan, USER_GLOBAL_EVENT_NAMED_GLOBS } from "./claude-context.mjs";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when every ANSI control introducer in `text` belongs to a display-only
|
|
3
|
+
* SGR color sequence (so stripping the ANSI removes only cosmetic styling,
|
|
4
|
+
* nothing that could move the cursor, erase, or carry a payload). Recognizes
|
|
5
|
+
* both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
|
|
6
|
+
*
|
|
7
|
+
* Answered by the STRIPPER'S OWN tokenizer: scanAnsi emits one token per raw
|
|
8
|
+
* introducer — 7-bit ESC and the whole C1 block, so a C1 cursor-move
|
|
9
|
+
* (`U+009B 2J`), a C1-OSC string (`U+009D … BEL`), a C1-DCS/APC payload and a
|
|
10
|
+
* lone or partial escape each yield a non-SGR token — and the predicate is
|
|
11
|
+
* "every token is SGR". Because the same scan decides what Layer 1 splices,
|
|
12
|
+
* this can no longer report "colour only" for bytes the stripper leaves behind.
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
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;
|
|
48
|
+
/**
|
|
49
|
+
* The agent-facing "Stripped: …" note for a Layer-1 strip: the removed category
|
|
50
|
+
* labels, the LONG RUN marker when the de-ANSI'd text still holds a
|
|
51
|
+
* payload-length invisible run, and a pointer to recover the bytes — a hex dump
|
|
52
|
+
* is ASCII, so it passes through sanitization untouched. The single source of
|
|
53
|
+
* this note, shared by the `sanitize` convenience entry and the tool-output
|
|
54
|
+
* pipeline.
|
|
55
|
+
* @param {string[]} invisFound CATEGORY codes applyLayer1 reported removing
|
|
56
|
+
* @param {string} deAnsi ANSI-stripped text (invisible runs intact), for the LONG_RUN probe
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function describeStripped(invisFound: string[], deAnsi: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Count the PAYLOAD invisible code points in `text`: those the carve-out would
|
|
62
|
+
* strip, excluding ZWNJ/ZWJ (and emoji VS16) that do real rendering work.
|
|
63
|
+
* Consumers that gate on invisible density (e.g. the prompt classifier's scatter
|
|
64
|
+
* threshold) use this so legitimate dense multilingual prose is not mistaken for
|
|
65
|
+
* a hidden channel.
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
export function countPayloadInvisible(text: string): number;
|
|
70
|
+
/**
|
|
71
|
+
* The `text` with every carve-out-PRESERVABLE invisible (joiners/selectors/tags/
|
|
72
|
+
* blank fillers doing real rendering work) replaced by a space, leaving only the
|
|
73
|
+
* PAYLOAD invisibles in place. The LONG_RUN injection probe runs over this so a
|
|
74
|
+
* legitimate emoji/flag/variation sequence never trips the "possible injection
|
|
75
|
+
* payload" marker (alert fatigue), while a genuine hidden run still surfaces.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function payloadInvisibleView(text: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* The first payload-invisible LONG RUN in `text`, or null when there is none.
|
|
82
|
+
*
|
|
83
|
+
* THE definition of "this text carries a hidden run", shared by every consumer
|
|
84
|
+
* that has an opinion about one: the strip's `[LONG RUN — possible injection
|
|
85
|
+
* payload]` marker, the prompt gate's block decision, and the tool-output
|
|
86
|
+
* severity tier. They used to spell it twice, and differently — the marker
|
|
87
|
+
* probed the PAYLOAD view while the prompt gate probed the raw text, so a
|
|
88
|
+
* legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
|
|
89
|
+
* quietly enough to BLOCK a prompt while the strip that saw the same text
|
|
90
|
+
* declined to even flag it. Masking the preserved invisibles is the right half
|
|
91
|
+
* of that disagreement: a run the carve-out keeps is rendering work, not a
|
|
92
|
+
* channel, and the joiners it does NOT keep are counted as payload anyway (see
|
|
93
|
+
* {@link countEffectiveInvisible}).
|
|
94
|
+
*
|
|
95
|
+
* Because the view replaces only PRESERVED invisibles (and visible characters)
|
|
96
|
+
* with spaces, a match consists solely of payload code points and is therefore
|
|
97
|
+
* byte-identical to the corresponding span of `text` — so a caller may report
|
|
98
|
+
* the sample verbatim.
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @returns {string | null}
|
|
101
|
+
*/
|
|
102
|
+
export function payloadLongRunSample(text: string): string | null;
|
|
103
|
+
/**
|
|
104
|
+
* How many invisible code points in `text` the strip layer treats as PAYLOAD:
|
|
105
|
+
* the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
|
|
106
|
+
* genuine linguistic context but exceed the carve-out's preservation budget.
|
|
107
|
+
*
|
|
108
|
+
* The surplus term closes the preserved-joiner covert channel (O3):
|
|
109
|
+
* `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
|
|
110
|
+
* an attacker who alternates `letter joiner letter joiner …` — every joiner
|
|
111
|
+
* legitimately between two cursive letters — counts as ZERO there. The strip
|
|
112
|
+
* layer already refuses that (it preserves joiners only up to
|
|
113
|
+
* TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
|
|
114
|
+
* so the surplus is read back OFF the strip — the SSOT — rather than by
|
|
115
|
+
* re-deriving the budget here, which is what would drift.
|
|
116
|
+
*
|
|
117
|
+
* A leading BOM is preserved by the strip but counted by
|
|
118
|
+
* {@link countPayloadInvisible}, so the difference can go slightly negative;
|
|
119
|
+
* hence the clamp.
|
|
120
|
+
* @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
|
|
121
|
+
* @returns {number}
|
|
122
|
+
*/
|
|
123
|
+
export function countEffectiveInvisible(text: string): number;
|
|
124
|
+
/**
|
|
125
|
+
* True when the invisible characters in `text` are INCIDENTAL: no hidden run,
|
|
126
|
+
* and too few of them in total to carry an instruction.
|
|
127
|
+
*
|
|
128
|
+
* This is a severity line, not a strip line — the bytes are removed either way
|
|
129
|
+
* (see ../src/severity.mjs). It exists because a single soft hyphen in a
|
|
130
|
+
* pasted paragraph, or one variation selector a font demanded, raised the exact
|
|
131
|
+
* `WARNING: Tool output sanitized` an encoded payload does, and a warning that
|
|
132
|
+
* fires on a stray character in ordinary prose is one operators learn to skip.
|
|
133
|
+
*
|
|
134
|
+
* The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
|
|
135
|
+
* "payload length" — applied to the WHOLE text rather than to one run, so it is
|
|
136
|
+
* strictly stronger than the run probe: fewer than ten payload-invisible code
|
|
137
|
+
* points, however they are distributed, cannot spell a smuggled instruction (ten
|
|
138
|
+
* tag characters are ten ASCII letters). Deliberately NOT the far looser
|
|
139
|
+
* {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
|
|
140
|
+
* tag characters is a short sentence, and staying quiet about a short sentence
|
|
141
|
+
* hidden in a tool result is not a trade worth making.
|
|
142
|
+
* @param {string} text ANSI-stripped text, invisible runs intact
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isIncidentalInvisible(text: string): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Strip payload-capable invisible chars and report which categories were
|
|
148
|
+
* removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
|
|
149
|
+
* interior BOMs and all soft hyphens (U+00AD) are stripped, since either can
|
|
150
|
+
* encode hidden instructions. ZWNJ/ZWJ survive only in a linguistic context
|
|
151
|
+
* (see the carve-out above). `found` names exactly the categories stripped, so
|
|
152
|
+
* a caller never warns about a strip the carve-out skipped.
|
|
153
|
+
*
|
|
154
|
+
* `originalText` is the pre-processing text (before any ANSI strip) used ONLY to
|
|
155
|
+
* decide whether a leading BOM is genuinely leading: an interior BOM that an
|
|
156
|
+
* ANSI-strip left at index 0 of `text` (e.g. `ESC[m + interior U+FEFF`) must NOT be treated as a
|
|
157
|
+
* legitimate leading marker. Defaults to `text` for the common single-arg call.
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @param {string} [originalText]
|
|
160
|
+
* @returns {{ cleaned: string, found: string[] }}
|
|
161
|
+
*/
|
|
162
|
+
export function stripInvisibleWithReport(text: string, originalText?: string): {
|
|
163
|
+
cleaned: string;
|
|
164
|
+
found: string[];
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Strip payload-capable invisible chars (cleaned text only). See
|
|
168
|
+
* stripInvisibleWithReport for the BOM and ZWNJ/ZWJ carve-out semantics.
|
|
169
|
+
* @param {string} text
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function stripInvisible(text: string): string;
|
|
173
|
+
export const VS: string;
|
|
174
|
+
export const ZERO_WIDTH_MN: "\u034F\u17B4\u17B5";
|
|
175
|
+
export const BLANK_NON_CF: string;
|
|
176
|
+
export const CATEGORY: Readonly<{
|
|
177
|
+
CF: "cf-format";
|
|
178
|
+
VARIATION_SELECTORS: "variation-selectors";
|
|
179
|
+
BLANK_FILLERS: "blank-fillers";
|
|
180
|
+
ANSI: "ansi";
|
|
181
|
+
LONE_SURROGATES: "lone-surrogates";
|
|
182
|
+
HTML_COMMENTS: "html-comments";
|
|
183
|
+
HIDDEN_HTML: "hidden-html";
|
|
184
|
+
EXFIL_URLS: "exfil-urls";
|
|
185
|
+
CONFUSABLE_HOST: "confusable-host";
|
|
186
|
+
}>;
|
|
187
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
188
|
+
export const CATEGORY_LABELS: Readonly<Record<string, string>>;
|
|
189
|
+
/** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
|
|
190
|
+
export const CHECKS: Array<[string, RegExp]>;
|
|
191
|
+
export const STRIP: RegExp;
|
|
192
|
+
export { SGR_RE } from "./ansi.mjs";
|
|
193
|
+
export const LONG_RUN_THRESHOLD: 10;
|
|
194
|
+
/** Total invisible-char count above which a file/prompt is treated as
|
|
195
|
+
* payload-capable even without a long run (threshold-evasion catch). */
|
|
196
|
+
export const SCATTERED_THRESHOLD: 30;
|
|
197
|
+
/**
|
|
198
|
+
* The long-run pattern, declaratively: {@link LONG_RUN_THRESHOLD} or more
|
|
199
|
+
* consecutive {@link STRIP} code points.
|
|
200
|
+
*
|
|
201
|
+
* Scan a document with {@link findLongRuns}, not with this: `exec`/`test`
|
|
202
|
+
* throw `RangeError: Maximum call stack size exceeded` once a run passes
|
|
203
|
+
* ~8.4 M code points, because V8 pushes one backtrack entry per iteration of
|
|
204
|
+
* an unbounded quantifier onto a stack capped at 64 MB. This stays public as
|
|
205
|
+
* the pattern itself, and as the independent oracle the scan is differenced
|
|
206
|
+
* against (test/invisible-fast-path.test.mjs).
|
|
207
|
+
*/
|
|
208
|
+
export const LONG_RUN_RE: RegExp;
|
|
209
|
+
export const CONSECUTIVE_JOINER_CAP: 8;
|
|
210
|
+
export const CONSECUTIVE_SELECTOR_CAP: 8;
|
|
211
|
+
export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
|
|
212
|
+
export const PRESERVED_JOINER_PER_VISIBLE: 8;
|
|
213
|
+
export const PRESERVE_HARD_CAP: 64;
|
|
214
|
+
export const TOTAL_PRESERVED_BLANK_BUDGET: 16;
|
|
215
|
+
export const PRESERVED_BLANK_PER_ANCHOR: 2;
|
|
216
|
+
export const LINGUISTIC_SCRIPTS: string[];
|
|
217
|
+
export { BRAHMIC_CONSONANT_RANGES } from "./joining-type.mjs";
|