agent-sanitizer 2.25.0 → 2.26.1
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 +28 -6
- package/THREAT-MODEL.md +55 -3
- package/bin/sanitize-cli.mjs +12 -9
- package/claude-hooks/lib/control-plane.mjs +18 -2
- package/claude-hooks/lib/hook-timing.mjs +94 -5
- package/claude-hooks/sanitize-output.mjs +73 -27
- package/claude-hooks/scan-invisible-chars.mjs +31 -86
- package/package.json +1 -1
- package/src/claude-context.mjs +125 -0
- package/src/html.mjs +48 -10
- package/src/index.mjs +6 -5
- package/src/instructions.mjs +36 -6
- package/src/invisible.mjs +85 -5
- package/src/layer1.mjs +15 -0
- package/src/output.mjs +159 -54
- package/src/prompt.mjs +11 -28
- package/src/rehydrate.mjs +12 -7
- package/src/severity.mjs +97 -0
- package/src/view-map.mjs +154 -74
- package/types/claude-context.d.mts +88 -0
- package/types/claude-hooks/lib/hook-timing.d.mts +64 -0
- package/types/claude-hooks/sanitize-output.d.mts +8 -5
- package/types/claude-hooks/scan-invisible-chars.d.mts +13 -31
- package/types/html.d.mts +7 -1
- package/types/index.d.mts +5 -3
- package/types/instructions.d.mts +14 -4
- package/types/invisible.d.mts +66 -0
- package/types/layer1.d.mts +12 -0
- package/types/output.d.mts +29 -14
- package/types/severity.d.mts +83 -0
- package/types/src/claude-context.d.mts +88 -0
- package/types/view-map.d.mts +75 -45
package/types/index.d.mts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* its own removal.
|
|
11
11
|
*
|
|
12
12
|
* `found` names the categories neutralized; `warnings` carries the
|
|
13
|
-
* operator-facing notices
|
|
13
|
+
* operator-facing notices and `notes` the quiet tier (see `./severity.mjs`).
|
|
14
|
+
* `cleaned` is always a string, and a change only
|
|
14
15
|
* ever carries a warning (no silent suppression). `options` is optional and
|
|
15
16
|
* tolerates an explicit `null`/`undefined` (treated the same as omitted) —
|
|
16
17
|
* only a genuinely malformed `text` (not a string) throws, deliberately: a
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
*
|
|
21
22
|
* The layer bodies live in `./output.mjs`; this is a facade over them, not a
|
|
22
23
|
* second implementation (see the module doc). It narrows `sanitizeText`'s result
|
|
23
|
-
* to the
|
|
24
|
+
* to the four fields this entry promises — `modified`/`sgrNote`
|
|
24
25
|
* describe the tool-output pipeline's banner, and `reveal` is produced only by
|
|
25
26
|
* options this facade does not expose. `html` selects Layers 2 AND 3 together
|
|
26
27
|
* here, which is the surface this entry has always had; `sanitizeText` takes
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
* detection without Layer 2's splice.
|
|
29
30
|
* @param {string} text
|
|
30
31
|
* @param {{ html?: boolean } | null} [options]
|
|
31
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
|
|
32
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
|
|
32
33
|
*/
|
|
33
34
|
export function sanitize(text: string, options?: {
|
|
34
35
|
html?: boolean;
|
|
@@ -36,6 +37,7 @@ export function sanitize(text: string, options?: {
|
|
|
36
37
|
cleaned: string;
|
|
37
38
|
found: string[];
|
|
38
39
|
warnings: string[];
|
|
40
|
+
notes: string[];
|
|
39
41
|
}>;
|
|
40
42
|
export { applyLayer1, isBenignAnsi, isBenignAnsiKinds, stripAnsiFully, LONE_SURROGATE_RE } from "./layer1.mjs";
|
|
41
43
|
export { stripInvisible, stripInvisibleWithReport, isSgrOnly, STRIP, SGR_RE, CHECKS, CATEGORY, CATEGORY_LABELS, LINGUISTIC_SCRIPTS, VS, BLANK_NON_CF, LONG_RUN_RE, LONG_RUN_THRESHOLD, SCATTERED_THRESHOLD } from "./invisible.mjs";
|
package/types/instructions.d.mts
CHANGED
|
@@ -40,23 +40,32 @@ export function scanText(content: string): Array<{
|
|
|
40
40
|
* cannot be resolved (a dangling symlink or unreadable entry inside the
|
|
41
41
|
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
42
42
|
* project.
|
|
43
|
+
*
|
|
44
|
+
* `exclude` prunes the WALK, which is where a wide glob's cost actually is —
|
|
45
|
+
* a pattern that merely fails to match a bulk directory still pays to read it.
|
|
46
|
+
* It is composed with, never replaces, the unconditional `node_modules` prune:
|
|
47
|
+
* a caller narrowing the scan must not be able to widen it into a dependency
|
|
48
|
+
* tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
43
49
|
* @param {string[]} globs
|
|
44
|
-
* @param {{ cwd?: string }} [options]
|
|
50
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
45
51
|
* @returns {string[]}
|
|
46
52
|
*/
|
|
47
|
-
export function findInstructionFiles(globs: string[], { cwd }?: {
|
|
53
|
+
export function findInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
48
54
|
cwd?: string;
|
|
55
|
+
exclude?: (entry: string) => boolean;
|
|
49
56
|
}): string[];
|
|
50
57
|
/**
|
|
51
58
|
* Scan every instruction file matched by `globs` and return only those with
|
|
52
59
|
* findings, each path reported relative to `cwd`. Unreadable/missing files are
|
|
53
60
|
* skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
|
|
54
61
|
* @param {string[]} globs
|
|
55
|
-
* @param {{ cwd?: string }} [options]
|
|
62
|
+
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
63
|
+
* `exclude` is forwarded to {@link findInstructionFiles}
|
|
56
64
|
* @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
|
|
57
65
|
*/
|
|
58
|
-
export function scanInstructionFiles(globs: string[], { cwd }?: {
|
|
66
|
+
export function scanInstructionFiles(globs: string[], { cwd, exclude }?: {
|
|
59
67
|
cwd?: string;
|
|
68
|
+
exclude?: (entry: string) => boolean;
|
|
60
69
|
}): Array<{
|
|
61
70
|
file: string;
|
|
62
71
|
findings: ReturnType<typeof scanText>;
|
|
@@ -135,3 +144,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
|
|
|
135
144
|
* @returns {boolean}
|
|
136
145
|
*/
|
|
137
146
|
export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
|
|
147
|
+
export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, excludeFromContextScan } from "./claude-context.mjs";
|
package/types/invisible.d.mts
CHANGED
|
@@ -46,6 +46,72 @@ export function countPayloadInvisible(text: string): number;
|
|
|
46
46
|
* @returns {string}
|
|
47
47
|
*/
|
|
48
48
|
export function payloadInvisibleView(text: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The first payload-invisible LONG RUN in `text`, or null when there is none.
|
|
51
|
+
*
|
|
52
|
+
* THE definition of "this text carries a hidden run", shared by every consumer
|
|
53
|
+
* that has an opinion about one: the strip's `[LONG RUN — possible injection
|
|
54
|
+
* payload]` marker, the prompt gate's block decision, and the tool-output
|
|
55
|
+
* severity tier. They used to spell it twice, and differently — the marker
|
|
56
|
+
* probed the PAYLOAD view while the prompt gate probed the raw text, so a
|
|
57
|
+
* legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
|
|
58
|
+
* quietly enough to BLOCK a prompt while the strip that saw the same text
|
|
59
|
+
* declined to even flag it. Masking the preserved invisibles is the right half
|
|
60
|
+
* of that disagreement: a run the carve-out keeps is rendering work, not a
|
|
61
|
+
* channel, and the joiners it does NOT keep are counted as payload anyway (see
|
|
62
|
+
* {@link countEffectiveInvisible}).
|
|
63
|
+
*
|
|
64
|
+
* Because the view replaces only PRESERVED invisibles (and visible characters)
|
|
65
|
+
* with spaces, a match consists solely of payload code points and is therefore
|
|
66
|
+
* byte-identical to the corresponding span of `text` — so a caller may report
|
|
67
|
+
* the sample verbatim.
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function payloadLongRunSample(text: string): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* How many invisible code points in `text` the strip layer treats as PAYLOAD:
|
|
74
|
+
* the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
|
|
75
|
+
* genuine linguistic context but exceed the carve-out's preservation budget.
|
|
76
|
+
*
|
|
77
|
+
* The surplus term closes the preserved-joiner covert channel (O3):
|
|
78
|
+
* `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
|
|
79
|
+
* an attacker who alternates `letter joiner letter joiner …` — every joiner
|
|
80
|
+
* legitimately between two cursive letters — counts as ZERO there. The strip
|
|
81
|
+
* layer already refuses that (it preserves joiners only up to
|
|
82
|
+
* TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
|
|
83
|
+
* so the surplus is read back OFF the strip — the SSOT — rather than by
|
|
84
|
+
* re-deriving the budget here, which is what would drift.
|
|
85
|
+
*
|
|
86
|
+
* A leading BOM is preserved by the strip but counted by
|
|
87
|
+
* {@link countPayloadInvisible}, so the difference can go slightly negative;
|
|
88
|
+
* hence the clamp.
|
|
89
|
+
* @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
export function countEffectiveInvisible(text: string): number;
|
|
93
|
+
/**
|
|
94
|
+
* True when the invisible characters in `text` are INCIDENTAL: no hidden run,
|
|
95
|
+
* and too few of them in total to carry an instruction.
|
|
96
|
+
*
|
|
97
|
+
* This is a severity line, not a strip line — the bytes are removed either way
|
|
98
|
+
* (see ../src/severity.mjs). It exists because a single soft hyphen in a
|
|
99
|
+
* pasted paragraph, or one variation selector a font demanded, raised the exact
|
|
100
|
+
* `WARNING: Tool output sanitized` an encoded payload does, and a warning that
|
|
101
|
+
* fires on a stray character in ordinary prose is one operators learn to skip.
|
|
102
|
+
*
|
|
103
|
+
* The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
|
|
104
|
+
* "payload length" — applied to the WHOLE text rather than to one run, so it is
|
|
105
|
+
* strictly stronger than the run probe: fewer than ten payload-invisible code
|
|
106
|
+
* points, however they are distributed, cannot spell a smuggled instruction (ten
|
|
107
|
+
* tag characters are ten ASCII letters). Deliberately NOT the far looser
|
|
108
|
+
* {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
|
|
109
|
+
* tag characters is a short sentence, and staying quiet about a short sentence
|
|
110
|
+
* hidden in a tool result is not a trade worth making.
|
|
111
|
+
* @param {string} text ANSI-stripped text, invisible runs intact
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isIncidentalInvisible(text: string): boolean;
|
|
49
115
|
/**
|
|
50
116
|
* Strip payload-capable invisible chars and report which categories were
|
|
51
117
|
* removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
|
package/types/layer1.d.mts
CHANGED
|
@@ -99,3 +99,15 @@ export function applyLayer1(text: string): {
|
|
|
99
99
|
ansiKinds: string[];
|
|
100
100
|
};
|
|
101
101
|
export const LONE_SURROGATE_RE: RegExp;
|
|
102
|
+
/**
|
|
103
|
+
* What a reader is told when the ONLY thing a strip removed was inert ANSI (see
|
|
104
|
+
* {@link isBenignAnsiKinds}).
|
|
105
|
+
*
|
|
106
|
+
* It lives here, beside the predicate that decides it, because every entry point
|
|
107
|
+
* that can reach that verdict must say the same thing: the tool-output pipeline,
|
|
108
|
+
* the prompt gate's pass-with-note, and any host wiring its own. The wording is
|
|
109
|
+
* deliberately not `describeStripped`'s — "Stripped: ANSI escapes" names a
|
|
110
|
+
* category that reads like an attack, when the honest report is "these were
|
|
111
|
+
* colour codes, and here is how to look at the raw bytes".
|
|
112
|
+
*/
|
|
113
|
+
export const INERT_ANSI_NOTE: string;
|
package/types/output.d.mts
CHANGED
|
@@ -50,18 +50,26 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
|
|
|
50
50
|
* post-mutation invariants and forget the rest, and every string in the returned
|
|
51
51
|
* object has traversed Layer 4.
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
53
|
+
* Findings come back SPLIT BY SEVERITY (see ./severity.mjs): `warnings` holds
|
|
54
|
+
* everything injection-shaped — the banner a caller must show — and `notes`
|
|
55
|
+
* holds what happened but is not alarming. `warnings` therefore keeps exactly
|
|
56
|
+
* the meaning it always had, and a caller that ignores `notes` is no louder
|
|
57
|
+
* than before, just quieter about incidental bytes.
|
|
58
|
+
*
|
|
59
|
+
* `found` is the machine-readable twin, and the severity split does NOT reach
|
|
60
|
+
* it — the {@link CATEGORY} codes for what Layers 1-3 neutralized or flagged,
|
|
61
|
+
* in the order the layers ran, whichever tier described them. Layers 4 and 5
|
|
62
|
+
* have no category codes (their findings are the injected seam's own
|
|
63
|
+
* vocabulary), so they contribute findings only.
|
|
57
64
|
* @param {string} text
|
|
58
65
|
* @param {SanitizeTextOptions} [options]
|
|
59
|
-
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
66
|
+
* @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
|
|
60
67
|
*/
|
|
61
68
|
export function sanitizeText(text: string, options?: SanitizeTextOptions): Promise<{
|
|
62
69
|
cleaned: string;
|
|
63
70
|
found: string[];
|
|
64
71
|
warnings: string[];
|
|
72
|
+
notes: string[];
|
|
65
73
|
modified: boolean;
|
|
66
74
|
sgrNote: boolean;
|
|
67
75
|
reveal?: string;
|
|
@@ -82,8 +90,10 @@ export function isWalkableContainer(value: any): boolean;
|
|
|
82
90
|
/**
|
|
83
91
|
* Sanitize every string leaf of a tool-output value, preserving its shape (a
|
|
84
92
|
* structured tool output whose shape changes would be ignored by a harness,
|
|
85
|
-
* leaking the raw value). Non-string leaves pass through; `warnings`
|
|
86
|
-
*
|
|
93
|
+
* leaking the raw value). Non-string leaves pass through; `warnings` and
|
|
94
|
+
* `notes` accumulate across leaves, split by severity (see ./severity.mjs).
|
|
95
|
+
* `sgrNote` is the OR across leaves — true when SOME leaf was note-only — so a
|
|
96
|
+
* caller can still pick the quiet banner when no leaf raised a warning.
|
|
87
97
|
*
|
|
88
98
|
* Fails CLOSED on two hostile shapes that would otherwise throw a `RangeError`
|
|
89
99
|
* as an unhandled async rejection (a DoS that leaves the output un-sanitized):
|
|
@@ -99,9 +109,12 @@ export function isWalkableContainer(value: any): boolean;
|
|
|
99
109
|
* @param {SanitizeTextOptions} options
|
|
100
110
|
* @param {string[]} warnings
|
|
101
111
|
* @param {string[]} [reveals]
|
|
112
|
+
* @param {string[]} [notes] the NOTE-severity counterpart of `warnings`;
|
|
113
|
+
* appended last so an existing positional caller keeps working (it simply
|
|
114
|
+
* discards the notes, which is exactly as loud as before the split)
|
|
102
115
|
* @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
|
|
103
116
|
*/
|
|
104
|
-
export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[]): Promise<{
|
|
117
|
+
export function sanitizeValue(value: any, options: SanitizeTextOptions, warnings: string[], reveals?: string[], notes?: string[]): Promise<{
|
|
105
118
|
value: any;
|
|
106
119
|
modified: boolean;
|
|
107
120
|
sgrNote: boolean;
|
|
@@ -199,16 +212,18 @@ export type Layer5Result = {
|
|
|
199
212
|
};
|
|
200
213
|
/**
|
|
201
214
|
* The running state of one {@link sanitizeText} call. Layers read `text` and
|
|
202
|
-
* mutate it ONLY through {@link applyMutation}.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
215
|
+
* mutate it ONLY through {@link applyMutation}. Findings carry their own
|
|
216
|
+
* severity (see ./severity.mjs) and are split into `warnings`/`notes` at the
|
|
217
|
+
* single exit, so no layer can push into the wrong list. `found` is the
|
|
218
|
+
* machine-readable twin, unaffected by the split: the {@link CATEGORY} codes
|
|
219
|
+
* for whatever Layers 1-3 neutralized or flagged.
|
|
205
220
|
*/
|
|
206
221
|
export type PipelineState = {
|
|
207
222
|
text: string;
|
|
208
223
|
found: string[];
|
|
209
|
-
|
|
224
|
+
findings: import("./severity.mjs").Finding[];
|
|
210
225
|
modified: boolean;
|
|
211
|
-
|
|
226
|
+
unreportedChange: boolean;
|
|
212
227
|
};
|
|
213
228
|
export type SanitizeTextOptions = {
|
|
214
229
|
html?: boolean;
|
|
@@ -218,4 +233,4 @@ export type SanitizeTextOptions = {
|
|
|
218
233
|
sgrCarveOut?: boolean;
|
|
219
234
|
};
|
|
220
235
|
import { needsMarkdownPipeline } from "./gates.mjs";
|
|
221
|
-
export { describeRemoved, describeWarned } from "./warnings.mjs";
|
|
236
|
+
export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ severity: string, message: string }} Finding
|
|
3
|
+
* A single reportable outcome and how loudly to report it.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A WARNING-severity finding: injection-shaped, worth the banner.
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @returns {Finding}
|
|
9
|
+
*/
|
|
10
|
+
export function warning(message: string): Finding;
|
|
11
|
+
/**
|
|
12
|
+
* A NOTE-severity finding: reported, not alarming.
|
|
13
|
+
* @param {string} message
|
|
14
|
+
* @returns {Finding}
|
|
15
|
+
*/
|
|
16
|
+
export function note(message: string): Finding;
|
|
17
|
+
/**
|
|
18
|
+
* A finding at `severity` — the constructor for a caller that has already
|
|
19
|
+
* computed the tier as a boolean and would otherwise write the ternary itself.
|
|
20
|
+
* @param {boolean} isWarning
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @returns {Finding}
|
|
23
|
+
*/
|
|
24
|
+
export function finding(isWarning: boolean, message: string): Finding;
|
|
25
|
+
/**
|
|
26
|
+
* The messages of every WARNING-severity finding, in order.
|
|
27
|
+
* @param {readonly Finding[]} findings
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
export function warningMessages(findings: readonly Finding[]): string[];
|
|
31
|
+
/**
|
|
32
|
+
* The messages of every NOTE-severity finding, in order.
|
|
33
|
+
* @param {readonly Finding[]} findings
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function noteMessages(findings: readonly Finding[]): string[];
|
|
37
|
+
/**
|
|
38
|
+
* The one place a finding's LOUDNESS is decided, and the vocabulary every layer
|
|
39
|
+
* reports in.
|
|
40
|
+
*
|
|
41
|
+
* Every layer of this pipeline used to have exactly one volume. A cursor-spoofing
|
|
42
|
+
* ANSI payload and a single stray escape byte in a README produced the same
|
|
43
|
+
* `WARNING: Tool output sanitized` banner; so did one soft hyphen, and so did
|
|
44
|
+
* the `<script>` tag that every fetched web page carries. That is the failure
|
|
45
|
+
* mode a detector dies of: a banner that fires on every ordinary page teaches its
|
|
46
|
+
* reader to skip the banner, and then the one that mattered scrolls past too.
|
|
47
|
+
*
|
|
48
|
+
* So a finding carries a SEVERITY, and the two tiers mean specific things:
|
|
49
|
+
*
|
|
50
|
+
* WARNING — this text is injection-shaped. Something was hidden from a human
|
|
51
|
+
* reader, something was removed that a payload would have used, or a
|
|
52
|
+
* secret was redacted. Worth interrupting the reader for.
|
|
53
|
+
* NOTE — this happened, and here is how to look at it, but nothing about it
|
|
54
|
+
* is attack-shaped. Incidental bytes, or content that was PRESERVED
|
|
55
|
+
* and merely described.
|
|
56
|
+
*
|
|
57
|
+
* The tier never changes what the pipeline DOES: the same bytes are stripped,
|
|
58
|
+
* spliced and redacted either way, and a note is still reported. All that rides
|
|
59
|
+
* on it is which banner the operator sees, which is why a note is the right
|
|
60
|
+
* answer whenever the evidence is thin — an under-loud true finding is still
|
|
61
|
+
* delivered, while an over-loud false one costs the channel its credibility.
|
|
62
|
+
*
|
|
63
|
+
* Mechanism only, deliberately: WHICH findings qualify is each layer's own
|
|
64
|
+
* judgement, made where that layer's evidence lives (see isBenignAnsiKinds in
|
|
65
|
+
* ./layer1.mjs, isIncidentalInvisible in ./invisible.mjs, and the exfil/HTML
|
|
66
|
+
* tiers in ./output.mjs and ./index.mjs). This module owns the enum, the constructors and the
|
|
67
|
+
* queries so nobody spells `severity === "warning"` by hand.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* The closed severity vocabulary. Stable, machine-readable values: branch on
|
|
71
|
+
* these, not on the prose.
|
|
72
|
+
*/
|
|
73
|
+
export const SEVERITY: Readonly<{
|
|
74
|
+
NOTE: "note";
|
|
75
|
+
WARNING: "warning";
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* A single reportable outcome and how loudly to report it.
|
|
79
|
+
*/
|
|
80
|
+
export type Finding = {
|
|
81
|
+
severity: string;
|
|
82
|
+
message: string;
|
|
83
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one directory no instruction-file walk ever descends into. Its own
|
|
3
|
+
* function so the name is spelled once, and so the two predicates that need it
|
|
4
|
+
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
5
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function excludeNodeModules(entry: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Entries a context scan must not descend into or return: `node_modules`, and
|
|
11
|
+
* every child of a `.claude` directory that is not whitelisted context.
|
|
12
|
+
*
|
|
13
|
+
* The globs alone would already refuse to MATCH those files, but a glob walker
|
|
14
|
+
* calls this on directories as it walks and prunes the ones it rejects — which
|
|
15
|
+
* is where the cost actually is. Without the prune, a `.claude/worktrees/`
|
|
16
|
+
* holding a few repo checkouts is walked in full on every session start (and,
|
|
17
|
+
* because a doubled-star segment does cross into a dot directory when the
|
|
18
|
+
* pattern names one, a `.claude` NESTED inside a worktree was matched and
|
|
19
|
+
* scanned as if it were this session's context).
|
|
20
|
+
*
|
|
21
|
+
* A walker calls this with both bare names and root-relative paths, so it must
|
|
22
|
+
* answer for either; a bare name carries no `.claude` context and is judged only
|
|
23
|
+
* against `node_modules`.
|
|
24
|
+
* @param {string} entry a bare entry name or a path relative to the scan root
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function excludeFromContextScan(entry: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* WHICH files an agent loads as model context, as data: the glob set and the
|
|
30
|
+
* walk-pruning predicate that together define "everything Claude Code reads as
|
|
31
|
+
* instructions, and nothing else".
|
|
32
|
+
*
|
|
33
|
+
* This is the SINGLE SOURCE for that scope. It used to live inside
|
|
34
|
+
* `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
|
|
35
|
+
* knew the answer and nobody else did: `src/instructions.mjs` takes
|
|
36
|
+
* caller-supplied globs by design (no agent's convention is baked into the
|
|
37
|
+
* engine), so the CLI, the Python port and every downstream fork spelled their
|
|
38
|
+
* own approximation of this list — and an approximation that drifts either
|
|
39
|
+
* scans bulk data that can never reach the model (the 30-second session start
|
|
40
|
+
* this whitelist exists to fix) or MISSES a context directory entirely, which
|
|
41
|
+
* is a silent hole in the one scan standing between a poisoned instruction file
|
|
42
|
+
* and a session that loads it.
|
|
43
|
+
*
|
|
44
|
+
* It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
|
|
45
|
+
* two reasons: `src/instructions.mjs` re-exports it as the library's public
|
|
46
|
+
* door, and the hook imports it RELATIVELY — deliberately not through the
|
|
47
|
+
* `agent-sanitizer` specifier the plugin bundle pins to a published engine.
|
|
48
|
+
* This scope is hook POLICY, not engine behavior: it must ship and move with the
|
|
49
|
+
* hook that walks it, or a plugin built against an older pin would prune the
|
|
50
|
+
* wrong directories while believing it had scanned everything.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The `.claude/` subdirectories whose markdown Claude Code loads as model
|
|
54
|
+
* context. This is a WHITELIST, and that is the point: `.claude/` is also where
|
|
55
|
+
* tooling parks bulk data that is never loaded as context — `worktrees/`
|
|
56
|
+
* (entire checked-out copies of the repo), plus caches, transcripts and
|
|
57
|
+
* snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
|
|
58
|
+
* populated worktrees that is thousands of files READ at every session start:
|
|
59
|
+
* one report put it at 30 seconds of blocked startup, paid for scanning files
|
|
60
|
+
* that cannot reach the model.
|
|
61
|
+
*
|
|
62
|
+
* A whitelist, not a `worktrees` denylist, because the failure modes are not
|
|
63
|
+
* symmetric: an unlisted context directory costs a scan nobody asked for anyway
|
|
64
|
+
* (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
|
|
65
|
+
* while an unlisted BULK directory silently costs every future session its
|
|
66
|
+
* startup. Add an entry here when Claude Code starts loading a new `.claude/`
|
|
67
|
+
* subdirectory as context.
|
|
68
|
+
*/
|
|
69
|
+
export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Every glob whose matches Claude Code loads as model context: the
|
|
72
|
+
* per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
|
|
73
|
+
* the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
|
|
74
|
+
* containing directory — a load path that bypasses the PostToolUse sanitizer —
|
|
75
|
+
* so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
|
|
76
|
+
* uncleaned unless something scans it here.
|
|
77
|
+
*
|
|
78
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
79
|
+
* their own doubled-star-prefixed patterns: without them a directory-scoped
|
|
80
|
+
* skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
|
|
81
|
+
* load path — is never matched. That same rule is why the root `.claude` needs
|
|
82
|
+
* no separate entry: a leading doubled star matches zero segments, so the
|
|
83
|
+
* nested patterns cover the root tree too.
|
|
84
|
+
*
|
|
85
|
+
* Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
|
|
86
|
+
* MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
|
|
87
|
+
*/
|
|
88
|
+
export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
|
package/types/view-map.d.mts
CHANGED
|
@@ -1,37 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {{ placeholder: string, original: string, start: number }} RedactionPair
|
|
3
|
-
* @typedef {
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @typedef {"codePoint" | "utf16"} OffsetSpace
|
|
4
|
+
* Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
|
|
5
|
+
* what the redactor's map mode emits (Python indexes strings by code point);
|
|
6
|
+
* `utf16` is what every function in this module indexes by (JS
|
|
7
|
+
* `indexOf`/`slice`/`.length` count UTF-16 code units).
|
|
6
8
|
*/
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* A branded, frozen carrier from {@link makeFileView}, tagged with the space its
|
|
11
|
+
* `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
|
|
12
|
+
* typedef in the comment it sits in, so sharing one with the two above would
|
|
13
|
+
* make `RedactionPair` and `OffsetSpace` generic too.
|
|
14
|
+
* @template {OffsetSpace} S
|
|
15
|
+
* @typedef {{ readonly space: S, readonly text: string,
|
|
16
|
+
* readonly pairs: readonly RedactionPair[] }} FileView
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a redactor's map-mode result in a frozen, branded view tagged with the
|
|
20
|
+
* space its offsets are in.
|
|
9
21
|
*
|
|
10
22
|
* The redactor's own object is never touched. It used to be: the caller did
|
|
11
23
|
* `view.pairs = pairsToUtf16(view.text, view.pairs)`, an in-place mutation of a
|
|
12
24
|
* value returned from an INJECTED seam. A redactor that memoizes its map result
|
|
13
25
|
* (a reasonable thing for a caller to build) hands back the same object on the
|
|
14
|
-
* second identical call, which then
|
|
26
|
+
* second identical call, which then got converted a SECOND time — every
|
|
15
27
|
* placeholder preceded by an astral character shifts again and the same input
|
|
16
|
-
* yields a different verdict.
|
|
17
|
-
* that: the conversion is part of construction, the redactor's value is left
|
|
18
|
-
* alone, and every consumer asserts the brand rather than accepting a
|
|
19
|
-
* hand-assembled `{text, pairs}` whose offsets may or may not be converted.
|
|
28
|
+
* yields a different verdict.
|
|
20
29
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
30
|
+
* Construction no longer converts. It brands and records the space, and
|
|
31
|
+
* {@link toUtf16View} does the conversion behind a check that the input is
|
|
32
|
+
* still in code-point space — so `toUtf16View(alreadyConverted)` throws where
|
|
33
|
+
* `makeFileView(v.text, v.pairs)` on an existing view used to silently convert
|
|
34
|
+
* a second time. That was the one door this carrier left open.
|
|
26
35
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
36
|
+
* Both the array AND each pair object are copied before freezing, so nothing
|
|
37
|
+
* here reaches back into the seam's (possibly memoized) value, and a caller that
|
|
38
|
+
* later mutates its own pairs cannot change what this view resolves.
|
|
39
|
+
* @template {OffsetSpace} S
|
|
30
40
|
* @param {string} text redacted view text
|
|
31
|
-
* @param {RedactionPair[]} pairs redactor pairs, in
|
|
32
|
-
* @
|
|
41
|
+
* @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space`
|
|
42
|
+
* @param {S} space
|
|
43
|
+
* @returns {FileView<S>}
|
|
33
44
|
*/
|
|
34
|
-
export function makeFileView(text: string, pairs: RedactionPair[]): FileView
|
|
45
|
+
export function makeFileView<S extends OffsetSpace>(text: string, pairs: readonly RedactionPair[], space: S): FileView<S>;
|
|
35
46
|
/**
|
|
36
47
|
* Non-overlapping occurrence indices of `needle` in `haystack`.
|
|
37
48
|
* @param {string} haystack
|
|
@@ -77,24 +88,33 @@ export function alignDeletions(content: string, cleaned: string): {
|
|
|
77
88
|
* placeholder mis-anchors the edit onto the wrong bytes.
|
|
78
89
|
*
|
|
79
90
|
* Exactly once, though: applying it to its own output shifts every
|
|
80
|
-
* astral-preceded placeholder a second time
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* the
|
|
91
|
+
* astral-preceded placeholder a second time, and bare arrays of numbers give
|
|
92
|
+
* nothing to check that against. Prefer {@link toUtf16View}, which runs this
|
|
93
|
+
* behind a space check so the second application throws instead. This stays
|
|
94
|
+
* exported — it is public API on the `./view-map` subpath, and removing it
|
|
95
|
+
* would be a breaking change the release workflow cannot express (it caps
|
|
96
|
+
* automated bumps at minor) — for callers doing their own offset bookkeeping,
|
|
97
|
+
* who own the once-only discipline themselves.
|
|
85
98
|
* @param {string} text the redacted view text the offsets index into
|
|
86
|
-
* @param {
|
|
87
|
-
* @returns {
|
|
99
|
+
* @param {RedactionPair[]} pairs
|
|
100
|
+
* @returns {RedactionPair[]}
|
|
88
101
|
*/
|
|
89
|
-
export function pairsToUtf16(text: string, pairs:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
export function pairsToUtf16(text: string, pairs: RedactionPair[]): RedactionPair[];
|
|
103
|
+
/**
|
|
104
|
+
* The same view with its pair offsets re-expressed in UTF-16 code units — a NEW
|
|
105
|
+
* frozen carrier; `view` is untouched.
|
|
106
|
+
*
|
|
107
|
+
* The one-way door. Only a `"codePoint"` view is accepted, so converting an
|
|
108
|
+
* already-converted view throws rather than shifting every astral-preceded
|
|
109
|
+
* offset a second time — which either mis-anchors the edit or rejects it as
|
|
110
|
+
* cutting a placeholder, both from an input that was fine the first time it was
|
|
111
|
+
* seen. Offset range/sort/overlap validity is not this door's job — every
|
|
112
|
+
* carrier is checked at construction (see {@link assertPairsOrdered}), in
|
|
113
|
+
* whichever space it declares.
|
|
114
|
+
* @param {FileView<"codePoint">} view
|
|
115
|
+
* @returns {FileView<"utf16">}
|
|
116
|
+
*/
|
|
117
|
+
export function toUtf16View(view: FileView<"codePoint">): FileView<"utf16">;
|
|
98
118
|
/**
|
|
99
119
|
* Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction
|
|
100
120
|
* pairs it wholly contains, mapping across placeholder expansion (view →
|
|
@@ -108,12 +128,12 @@ export function pairsToUtf16(text: string, pairs: {
|
|
|
108
128
|
* and a mis-attributed run would mis-anchor the edit.
|
|
109
129
|
* @param {string} content disk file content
|
|
110
130
|
* @param {string} cleaned Layer-1 view of `content`
|
|
111
|
-
* @param {FileView} view
|
|
131
|
+
* @param {FileView<"utf16">} view
|
|
112
132
|
* @param {{start: number, deleted: string}[]} deletions
|
|
113
133
|
* @param {number} viewStart
|
|
114
134
|
* @param {number} viewEnd
|
|
115
135
|
*/
|
|
116
|
-
export function resolveSpan(content: string, cleaned: string, view: FileView
|
|
136
|
+
export function resolveSpan(content: string, cleaned: string, view: FileView<"utf16">, deletions: {
|
|
117
137
|
start: number;
|
|
118
138
|
deleted: string;
|
|
119
139
|
}[], viewStart: number, viewEnd: number): {
|
|
@@ -185,11 +205,11 @@ export function spliceOrdered(text: string, matches: {
|
|
|
185
205
|
* was never part of the secret); interior runs are included. Callers use these
|
|
186
206
|
* to detect an edit whose on-disk footprint intrudes into bytes the model was
|
|
187
207
|
* never shown.
|
|
188
|
-
* @param {FileView} view
|
|
208
|
+
* @param {FileView<"utf16">} view
|
|
189
209
|
* @param {{start: number, deleted: string}[]} deletions
|
|
190
210
|
* @returns {{start: number, end: number}[]}
|
|
191
211
|
*/
|
|
192
|
-
export function pairDiskSpans(view: FileView
|
|
212
|
+
export function pairDiskSpans(view: FileView<"utf16">, deletions: {
|
|
193
213
|
start: number;
|
|
194
214
|
deleted: string;
|
|
195
215
|
}[]): {
|
|
@@ -221,10 +241,20 @@ export type RedactionPair = {
|
|
|
221
241
|
start: number;
|
|
222
242
|
};
|
|
223
243
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
244
|
+
* Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
|
|
245
|
+
* what the redactor's map mode emits (Python indexes strings by code point);
|
|
246
|
+
* `utf16` is what every function in this module indexes by (JS
|
|
247
|
+
* `indexOf`/`slice`/`.length` count UTF-16 code units).
|
|
226
248
|
*/
|
|
227
|
-
export type
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
export type OffsetSpace = "codePoint" | "utf16";
|
|
250
|
+
/**
|
|
251
|
+
* A branded, frozen carrier from {@link makeFileView}, tagged with the space its
|
|
252
|
+
* `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
|
|
253
|
+
* typedef in the comment it sits in, so sharing one with the two above would
|
|
254
|
+
* make `RedactionPair` and `OffsetSpace` generic too.
|
|
255
|
+
*/
|
|
256
|
+
export type FileView<S extends OffsetSpace> = {
|
|
257
|
+
readonly space: S;
|
|
258
|
+
readonly text: string;
|
|
259
|
+
readonly pairs: readonly RedactionPair[];
|
|
230
260
|
};
|