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.
- package/THREAT-MODEL.md +18 -6
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/prompt.mjs +9 -6
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
|
@@ -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 {{
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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;
|
package/types/confusables.d.mts
CHANGED
|
@@ -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
|
|
117
|
-
*
|
|
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.
|
package/types/invisible.d.mts
CHANGED
|
@@ -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
|
|
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
|
|
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]>;
|
package/types/layer1.d.mts
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
*/
|