agent-sanitizer 2.24.2 → 2.26.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.
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Milliseconds as the seconds string every notice below prints.
3
+ *
4
+ * Rounds tenths half-UP from an exact integer count of hundredths, rather than
5
+ * `(ms / 1000).toFixed(1)`: the shell port of this module
6
+ * (plugin/scripts/lib/hook-timing.sh) has to produce the byte-identical string
7
+ * with integer arithmetic, and `toFixed` rounds the underlying double — so 1150
8
+ * would print "1.1" here (1.15 is below its decimal value as a double) and "1.2"
9
+ * there. `ms / 100` lands exactly on a half only when `ms` ends in 50, and every
10
+ * such quotient is dyadic, so this rounding is exact for every input.
11
+ * @param {number} ms
12
+ * @returns {string}
13
+ */
14
+ export function formatSeconds(ms: number): string;
1
15
  /**
2
16
  * Run `work`, charging its whole duration to provisioning so no timer running
3
17
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -37,6 +51,43 @@ export function startHookTimer(now?: () => number): () => number;
37
51
  * @returns {string | null}
38
52
  */
39
53
  export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number): string | null;
54
+ /**
55
+ * The line for a ONE-TIME provisioning step that overran
56
+ * {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
57
+ *
58
+ * Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message
59
+ * says "every affected call pays it", which is false here and would send the
60
+ * reader hunting a per-call cost that does not exist. What is actionable about a
61
+ * slow install is the installer (uv resolves in a fraction of pip's time) and
62
+ * the fact that a repeat means the idempotence check is broken — so this asks
63
+ * for a report only on the repeat, which is the version of this that is a bug.
64
+ *
65
+ * The one caller is the shell provisioner, whose port of this module
66
+ * (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and
67
+ * this definition are pinned to each other by a contract test rather than left
68
+ * as two independently-worded copies.
69
+ * @param {string} stepName
70
+ * @param {number} elapsedMs
71
+ * @param {number} [thresholdMs]
72
+ * @returns {string | null}
73
+ */
74
+ export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number): string | null;
75
+ /**
76
+ * Write the slow-hook notice to stderr and return it, or return null when the
77
+ * run was within budget (writing nothing, so the quiet path stays quiet).
78
+ *
79
+ * The one place the notice reaches stderr: every reporter below needs the
80
+ * transcript copy, and a hook whose run ENDED IN AN ERROR has nothing but this —
81
+ * its verdict is the fail-closed one its `onError` composed, and diluting that
82
+ * message with a performance aside would bury the fault. A judge that spent
83
+ * thirty seconds and then threw is exactly the case the timing exists to name,
84
+ * so the error path measures and reports; it just reports on the human channel.
85
+ * @param {string} hookName
86
+ * @param {number} elapsedMs
87
+ * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
88
+ * @returns {string | null}
89
+ */
90
+ export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeErr?: (chunk: string) => void): string | null;
40
91
  /**
41
92
  * `verdict` with the slow-hook notice folded into its `additional_context`, or
42
93
  * the verdict untouched when the run was within budget. Also writes the notice
@@ -104,3 +155,16 @@ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventNam
104
155
  * means something is actually wrong, not that the machine is busy.
105
156
  */
106
157
  export const SLOW_HOOK_THRESHOLD_MS: 1000;
158
+ /**
159
+ * Wall-clock a ONE-TIME provisioning step may spend before it is reported.
160
+ *
161
+ * Two orders of magnitude above {@link SLOW_HOOK_THRESHOLD_MS}, because it
162
+ * measures something categorically different: a dependency install that a
163
+ * session pays once, not a cost every tool call repeats. A cold `uv` install of
164
+ * the redactor engine is seconds and a cold `pip` one can be tens of them, so a
165
+ * budget anywhere near a second would report every first session — the alert
166
+ * fatigue this whole module exists to avoid. Past a minute, something is
167
+ * actually wrong (a serial pip resolve, a wedged mirror, or an idempotence bug
168
+ * re-provisioning every session), which is worth saying out loud.
169
+ */
170
+ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
@@ -56,13 +56,14 @@
56
56
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
57
57
  * all leaves of one hook run; a direct caller gets a fresh full budget
58
58
  * @param {SanitizeExtensions} [ext]
59
- * @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
59
+ * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
60
60
  */
61
61
  export function sanitizeText(text: string, toolName: string, deadline?: {
62
62
  remainingMs: () => number;
63
63
  }, ext?: SanitizeExtensions): Promise<{
64
64
  cleaned: string;
65
65
  warnings: string[];
66
+ notes: string[];
66
67
  modified: boolean;
67
68
  sgrNote: boolean;
68
69
  reveal?: string;
@@ -77,10 +78,10 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
77
78
  * too (a connector can hide a secret in a field name); non-string leaves
78
79
  * (booleans, numbers, null) pass through untouched, and `warnings` accumulates
79
80
  * across leaves.
80
- * `sgrNote` is the OR across leaves: true when some leaf was an SGR-only strip.
81
+ * `sgrNote` is the OR across leaves: true when some leaf came back note-only.
81
82
  * `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
82
- * removed something) for the orchestrator to persist same mutated-accumulator
83
- * shape as `warnings`.
83
+ * removed something) for the orchestrator to persist, and `notes` the leaves'
84
+ * NOTE-severity findings — same mutated-accumulator shape as `warnings`.
84
85
  * @param {any} value
85
86
  * @param {string} toolName
86
87
  * @param {string[]} warnings
@@ -88,11 +89,13 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
88
89
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
89
90
  * every leaf of this value (created once by the top-level caller)
90
91
  * @param {SanitizeExtensions} [ext]
92
+ * @param {string[]} [notes] appended last so an existing caller's positional
93
+ * arguments keep their meaning
91
94
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
92
95
  */
93
96
  export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
94
97
  remainingMs: () => number;
95
- }, ext?: SanitizeExtensions): Promise<{
98
+ }, ext?: SanitizeExtensions, notes?: string[]): Promise<{
96
99
  value: any;
97
100
  modified: boolean;
98
101
  sgrNote: boolean;
@@ -71,24 +71,6 @@ export function cliMain(opts?: {
71
71
  trace?: import("./lib/trace.mjs").TraceFn;
72
72
  scan?: () => ReturnType<typeof scanProject>;
73
73
  }): Promise<void>;
74
- /**
75
- * The `.claude/` subdirectories whose markdown Claude Code loads as model
76
- * context. This is a WHITELIST, and that is the point: `.claude/` is also where
77
- * tooling parks bulk data that is never loaded as context — `worktrees/`
78
- * (entire checked-out copies of the repo), plus caches, transcripts and
79
- * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
80
- * populated worktrees that is thousands of files READ at every session start:
81
- * one report put it at 30 seconds of blocked startup, paid for scanning files
82
- * that cannot reach the model.
83
- *
84
- * A whitelist, not a `worktrees` denylist, because the failure modes are not
85
- * symmetric: an unlisted context directory costs a scan this hook was never
86
- * asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
87
- * tool reads them), while an unlisted BULK directory silently costs every future
88
- * session its startup. Add an entry here when Claude Code starts loading a new
89
- * `.claude/` subdirectory as context.
90
- */
91
- export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
92
74
  /**
93
75
  * @param {string} filePath
94
76
  * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
@@ -99,6 +81,8 @@ export function scanFile(filePath: string): Array<{
99
81
  method: string;
100
82
  decoded: string;
101
83
  }>;
84
+ import { CLAUDE_CONTEXT_SUBDIRS } from "../src/claude-context.mjs";
85
+ import { CLAUDE_INSTRUCTION_GLOBS } from "../src/claude-context.mjs";
102
86
  /**
103
87
  * @param {string} run
104
88
  * @returns {{ method: string, decoded: string }}
@@ -109,19 +93,17 @@ export function decodeRun(run: string): {
109
93
  };
110
94
  /**
111
95
  * Every file under `dir` that Claude Code loads as model context: the
112
- * subdirectory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and the
113
- * whitelisted `.claude/` markdown (see {@link CLAUDE_CONTEXT_SUBDIRS}). Claude
114
- * Code loads these on entry to their containing directory — a load path that
115
- * bypasses the PostToolUse sanitizer — so a payload planted in e.g.
116
- * `packages/foo/CLAUDE.md` reaches the model uncleaned unless it is scanned
117
- * here. Skips node_modules.
96
+ * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
97
+ * the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
98
+ * containing directory — a load path that bypasses the PostToolUse sanitizer —
99
+ * so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
100
+ * uncleaned unless it is scanned here.
118
101
  *
119
- * `**` does not descend into dot directories, so NESTED `.claude/` trees need
120
- * their own doubled-star-prefixed patterns: without them a directory-scoped skill at
121
- * `packages/foo/.claude/skills/x/SKILL.md` model context by the same load
122
- * path is never scanned. That same rule is why the root `.claude` needs no
123
- * separate walk: a leading doubled star matches zero segments, so the nested
124
- * patterns cover the root tree too.
102
+ * The scope itself which globs, and which directories the walk must prune
103
+ * is the library's {@link CLAUDE_INSTRUCTION_GLOBS} /
104
+ * {@link excludeFromContextScan}, so this hook and every other consumer read one
105
+ * list (see src/claude-context.mjs for why it is imported relatively rather than
106
+ * through the `agent-sanitizer` specifier the plugin bundle pins).
125
107
  * @param {string} dir
126
108
  * @returns {string[]}
127
109
  */
@@ -147,4 +129,4 @@ export function formatReport(allFindings: Array<{
147
129
  decoded: string;
148
130
  }>;
149
131
  }>): string;
150
- export { ALERT_FILE, ALERT_ACK_FILE };
132
+ export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, ALERT_FILE, ALERT_ACK_FILE };
package/types/html.d.mts CHANGED
@@ -93,11 +93,17 @@ export function urlHost(url: string): string;
93
93
  * and HTML attributes (src/href/background/srcset/ping, form action/formaction,
94
94
  * meta-refresh). Detection only — the text is never modified; the caller
95
95
  * surfaces the threats as a warning.
96
+ *
97
+ * `autoFetched` marks a threat that needs no deliberate act to fire — a
98
+ * rendered image, a stylesheet, a form target, a meta refresh — as opposed to a
99
+ * link somebody has to follow. Both are reported; the caller uses it to decide
100
+ * how loudly (see the exfil tier in ./output.mjs).
96
101
  * @param {string} text
97
- * @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
102
+ * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
98
103
  */
99
104
  export function detectExfil(text: string): Array<{
100
105
  isImage: boolean;
106
+ autoFetched: boolean;
101
107
  reason: string;
102
108
  target: string;
103
109
  }> | null;
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. `cleaned` is always a string, and a change only
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 three fields this entry has always promised — `modified`/`sgrNote`
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";
@@ -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";
@@ -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;
@@ -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;
@@ -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
- * `found` is the machine-readable twin of `warnings` — the {@link CATEGORY}
54
- * codes for what Layers 1-3 neutralized or flagged, in the order the layers ran.
55
- * Layers 4 and 5 have no category codes (their findings are the injected seam's
56
- * own vocabulary), so they contribute warnings only.
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
- * accumulates across leaves. `sgrNote` is the OR across leaves.
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}. `found` is the machine-readable
203
- * twin of `warnings`: the {@link CATEGORY} codes for whatever Layers 1-3
204
- * neutralized or flagged.
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
- warnings: string[];
224
+ findings: import("./severity.mjs").Finding[];
210
225
  modified: boolean;
211
- sgrNote: boolean;
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[];