agent-sanitizer 2.19.3 → 2.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,86 @@
1
+ /**
2
+ * Scan every instruction file under the project, ACCOUNTING for every target
3
+ * the finder returned: `scanned + skipped.length === targets.length`, always.
4
+ *
5
+ * The accounting is the point. This scan is the only thing standing between a
6
+ * poisoned `CLAUDE.md` and a session that loads it as instructions, and its
7
+ * caller announces "clean" on the trace channel — the channel that exists so a
8
+ * MISSING announcement is loud. A per-file failure swallowed into an empty
9
+ * findings list turns "we could not read this file" into "this file is fine",
10
+ * which is the one lie this hook must never tell. So a file that cannot be read
11
+ * is REPORTED as unscanned, not dropped.
12
+ *
13
+ * ANY errno is a skip; only a non-filesystem throw propagates. The split is
14
+ * between "this file could not be read" (report it and keep scanning) and "this
15
+ * code is broken" (a TypeError from an unloaded binding — nothing here can be
16
+ * trusted, so it goes to the caller's declared failure posture). Catching only
17
+ * ENOENT would invert the enforcement: one EACCES target would discard the
18
+ * result for EVERY other instruction file, leaving them unscanned and
19
+ * un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
20
+ * nothing — so the SUSPICIOUS failure would get weaker enforcement than the
21
+ * benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
22
+ * split {@link autoCleanFindings} uses.
23
+ * @param {string} [dir] project root to scan (injectable for tests)
24
+ * @returns {{
25
+ * targets: string[],
26
+ * scanned: number,
27
+ * findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
28
+ * skipped: Array<{file: string, reason: string}>,
29
+ * }}
30
+ */
31
+ export function scanProject(dir?: string): {
32
+ targets: string[];
33
+ scanned: number;
34
+ findings: Array<{
35
+ file: string;
36
+ findings: ReturnType<typeof scanFile>;
37
+ }>;
38
+ skipped: Array<{
39
+ file: string;
40
+ reason: string;
41
+ }>;
42
+ };
43
+ /**
44
+ * The report for targets the scan could not read. Rendered into the alert the
45
+ * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
46
+ * checkpoint rather than as silence.
47
+ * @param {Array<{file: string, reason: string}>} skipped
48
+ * @returns {string}
49
+ */
50
+ export function formatSkipped(skipped: Array<{
51
+ file: string;
52
+ reason: string;
53
+ }>): string;
1
54
  /**
2
55
  * The hook's CLI: scan the instruction files, auto-clean what it can, persist
3
56
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
4
57
  * (which must claim the CLI slot before this module loads) can run the exact
5
58
  * same scan instead of duplicating it.
6
- * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
7
- * this scan announces engagement; a host with its own trace channel passes its
8
- * sink so the announcement lands where its detector reads (see lib/trace.mjs).
59
+ * @param {{
60
+ * trace?: import("./lib/trace.mjs").TraceFn,
61
+ * scan?: () => ReturnType<typeof scanProject>,
62
+ * }} [opts] `trace` is where this scan announces engagement; a host with its
63
+ * own trace channel passes its sink so the announcement lands where its
64
+ * detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
65
+ * FAULT path below — a scanner that throws something other than an errno, i.e.
66
+ * a bug — is drivable end to end; no filesystem state can force it, and an
67
+ * untested fault path is how a posture goes missing in the first place.
9
68
  * @returns {Promise<void>}
10
69
  */
11
- export function cliMain({ trace: sink }?: {
70
+ export function cliMain({ trace: sink, scan: runScan }?: {
12
71
  trace?: import("./lib/trace.mjs").TraceFn;
72
+ scan?: () => ReturnType<typeof scanProject>;
13
73
  }): Promise<void>;
74
+ /**
75
+ * @param {string} filePath
76
+ * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
77
+ */
78
+ export function scanFile(filePath: string): Array<{
79
+ line: number;
80
+ charCount: number;
81
+ method: string;
82
+ decoded: string;
83
+ }>;
14
84
  /**
15
85
  * @param {string} run
16
86
  * @returns {{ method: string, decoded: string }}
@@ -39,16 +109,6 @@ export function findMdFiles(dir: string): string[];
39
109
  * @returns {string[]}
40
110
  */
41
111
  export function findInstructionFiles(dir: string): string[];
42
- /**
43
- * @param {string} filePath
44
- * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
45
- */
46
- export function scanFile(filePath: string): Array<{
47
- line: number;
48
- charCount: number;
49
- method: string;
50
- decoded: string;
51
- }>;
52
112
  import { ALERT_FILE } from "./lib/invisible-alert.mjs";
53
113
  import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
54
114
  export let LONG_RUN_RE: RegExp;
@@ -113,8 +113,12 @@ export function normalizeConfusables(tool: string, toolInput: any, { scan, field
113
113
  *
114
114
  * ORDERING: the soundness argument assumes no later layer erases code points
115
115
  * from the same field, which would let an unmapped glyph the gate relied on
116
- * disappear after the decision. Layer 4 runs before sanitizeAuthoredContent on
117
- * Bash.command; keep it there.
116
+ * disappear after the decision — a zero-width run padded into a token suppresses
117
+ * the fold, and the erasing layer then removes the very evidence for skipping it.
118
+ * This fold does NOT run last: on Bash.command the invisible-char strip follows
119
+ * it. A caller that composes the two is therefore responsible for re-running
120
+ * this fold on the post-erasure text until it reports nothing, which is what the
121
+ * hook driver in claude-hooks/lib/layer-pipeline.mjs does.
118
122
  *
119
123
  * Genuine non-confusable non-ASCII (accented Latin, CJK, emoji) is untouched
120
124
  * regardless, since a faithful scanner does not flag it.
@@ -1,8 +1,15 @@
1
1
  /**
2
2
  * True when every ANSI control introducer in `text` belongs to a display-only
3
- * SGR color sequence (so stripping the ANSI removed only cosmetic styling,
3
+ * SGR color sequence (so stripping the ANSI removes only cosmetic styling,
4
4
  * nothing that could move the cursor, erase, or carry a payload). Recognizes
5
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.
6
13
  * @param {string} text
7
14
  * @returns {boolean}
8
15
  */
@@ -84,7 +91,7 @@ export const CATEGORY_LABELS: Readonly<Record<string, string>>;
84
91
  /** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
85
92
  export const CHECKS: Array<[string, RegExp]>;
86
93
  export const STRIP: RegExp;
87
- export const SGR_RE: RegExp;
94
+ export { SGR_RE } from "./ansi.mjs";
88
95
  export const LONG_RUN_THRESHOLD: 10;
89
96
  /** Total invisible-char count above which a file/prompt is treated as
90
97
  * payload-capable even without a long run (threshold-evasion catch). */
@@ -96,3 +103,5 @@ export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
96
103
  export const PRESERVED_JOINER_PER_VISIBLE: 8;
97
104
  export const PRESERVE_HARD_CAP: 64;
98
105
  export const LINGUISTIC_SCRIPTS: string[];
106
+ /** @type {ReadonlyArray<readonly [string, number, number]>} */
107
+ export const BRAHMIC_CONSONANT_RANGES: ReadonlyArray<readonly [string, number, number]>;
@@ -19,16 +19,25 @@ export function stripAnsiFully(input: string): string;
19
19
  * raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
20
20
  * block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
21
21
  *
22
- * Removing an invisible character can reconstitute an escape its split hid from
23
- * the ANSI pass (`ESC`<ZWSP>`[32m` → `ESC[32m`), so strip ANSI again after the
24
- * invisible pass — but only when stripInvisible changed something, since
25
- * reconstitution is impossible otherwise and the re-strip is a wasted pass on
26
- * the hot clean path. The ANSI strip still cannot match an *incomplete*
27
- * reconstituted sequence (a lone `ESC[` left when an inner complete sequence is
28
- * removed from a nested split), so a final sweep removes every residual raw
29
- * introducer outright — that sweep, not the regex matching, is the guarantee
30
- * that no control introducer survives. `deAnsi` is the ANSI strip of the
31
- * original (invisible runs intact), the scope a LONG_RUN payload check needs.
22
+ * The two passes FEED each other in both directions, so neither ordering is
23
+ * enough on its own and the composition is iterated to a fixed point instead:
24
+ * removing an invisible char reconstitutes an escape its split hid
25
+ * (`ESC`<ZWSP>`[32m` → `ESC[32m`), and removing an escape makes two invisibles
26
+ * ADJACENT that were not (`م ZWJ ESC[m ZWJ م` → a joiner run the invisible pass
27
+ * classifies as a payload channel rather than linguistic). A pipeline with a
28
+ * fixed number of alternations always leaves one of those unanswered — this used
29
+ * to re-strip ANSI after the invisible pass and stop, so `applyLayer1` was not
30
+ * idempotent and the rehydrator's "re-cleaning reproduces the view" assumption
31
+ * did not hold.
32
+ *
33
+ * The residual sweep runs only once the composition is STABLE, because sweeping
34
+ * an introducer early destroys the sequence a later ANSI pass would have removed
35
+ * whole, promoting a hidden control to visible text. A final UNCONDITIONAL sweep
36
+ * follows the loop so the no-raw-introducer guarantee does not depend on the
37
+ * pass bound.
38
+ *
39
+ * `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
40
+ * scope a LONG_RUN payload check needs — not an intermediate of the loop.
32
41
  * @param {string} text
33
42
  * @returns {{ cleaned: string, deAnsi: string, found: string[] }}
34
43
  */