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.
@@ -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
- * @param {string} entry a bare entry name or a path relative to the scan root
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
- * A walker calls this with both bare names and root-relative paths, so it must
53
- * answer for either; a bare name carries no `.claude` context and is judged only
54
- * against `node_modules`.
55
- * @param {string} entry a bare entry name or a path relative to the scan root
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";