agent-sanitizer 2.23.2 → 2.24.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 CHANGED
@@ -55,7 +55,7 @@ the callback you inject for the agent-specific concern; `—` is a pure transfor
55
55
  | 3 | `/html` | Detect exfil-shaped URLs (payloads in query/path, embedded creds, `data:`/`javascript:`, off-origin redirects). Reports only. | — |
56
56
  | 4 | `/confusables` | Fold look-alike glyphs in tool-call input (paths, commands) to ASCII, closing a cross-script deny-rule bypass. Gated per token, so non-Latin prose passes through unfolded. | `scan` |
57
57
  | 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
58
- | 6 | `/prompt` | Classify a prompt pass / SGR-note / block on payload-capable invisible/ANSI content. | — |
58
+ | 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
59
59
  | 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
60
60
  | 8 | `/rehydrate` | Re-anchor a model Edit composed from the _sanitized_ view back onto real bytes; deny anything ambiguous or secret-exposing. | `io` |
61
61
  | — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
@@ -104,8 +104,11 @@ for a later span (overlapping spans resolve first-match-wins).
104
104
  Installing the plugin puts four hooks on every session, and this is what they
105
105
  buy you:
106
106
 
107
- 1. Your `CLAUDE.md`, `AGENTS.md` and `.claude/` markdown are scanned at session
108
- start for hidden-Unicode payloads and auto-cleaned where possible.
107
+ 1. Your `CLAUDE.md`, `AGENTS.md` and the context markdown under `.claude/` are
108
+ scanned at session start for hidden-Unicode payloads and auto-cleaned where
109
+ possible. Only the subdirectories Claude Code loads as context are walked, so
110
+ bulk data parked under `.claude/` (`worktrees/`, caches, transcripts) does not
111
+ slow startup.
109
112
  2. Prompts carrying payload-capable invisible or ANSI characters are blocked
110
113
  before they reach the model; pasted terminal color passes with a note.
111
114
  3. Look-alike glyphs in tool inputs are folded to ASCII, so a Cyrillic `а` can't
package/THREAT-MODEL.md CHANGED
@@ -66,9 +66,18 @@ visible text; a final unconditional sweep after the loop keeps the
66
66
  no-raw-introducer guarantee independent of the iteration bound. The result
67
67
  carries no raw ANSI introducer for _any_ input, and re-cleaning it reproduces
68
68
  it exactly—the idempotence the Edit-repair rehydrator's soundness gate assumes.
69
- One tokenizer answers every ANSI question (what to splice, and whether the only
70
- escape content is display-only SGR colour), so the stripper and the operator
71
- warning cannot disagree about what a sequence is. OSC strings (titles,
69
+ One tokenizer answers every ANSI question (what to splice, and whether what was
70
+ removed was INERT—display-only SGR colour, or a lone 7-bit `ESC` that opened
71
+ nothing at all), so the stripper and the operator warning cannot disagree about
72
+ what a sequence is. That inert/injection-shaped split is what keeps the warning
73
+ worth reading: a stray `ESC` sitting in a file is reported as a terse note, while
74
+ a cursor move, an erase, an OSC string, or a raw C1 introducer (which no
75
+ legitimate UTF-8 text carries, and which includes the DCS/SOS/PM/APC string
76
+ introducers) keeps the WARNING. An `ESC` that _opened_ a CSI it never completed
77
+ stays loud too: a terminal's CSI parser is stateful and keeps consuming what
78
+ follows until a final byte arrives, so `ESC[12 world` shows the human `orld`
79
+ while the model reads every word—the same model-sees/human-sees divergence a
80
+ complete sequence buys. OSC strings (titles,
72
81
  clickable-hyperlink URLs) are consumed as a
73
82
  whole, for every terminator form—ST (`ESC\` or 8-bit C1 ST U+009C) and the
74
83
  legacy BEL—and for the 8-bit C1 OSC introducer (U+009D); an _unterminated_ OSC
@@ -180,13 +189,15 @@ if a contaminated file cannot be rewritten.
180
189
  `./prompt` classifies a submitted prompt as **pass / pass-with-note / block** on
181
190
  payload-capable invisible Unicode and ANSI. A prompt-submission channel usually
182
191
  cannot rewrite the prompt in place, so the only neutralization is to block.
183
- One carve-out: a prompt whose only escape content is display-only SGR color
184
- passes with a note (pasting colored terminal output is the common case, and SGR
185
- cannot move the cursor, erase, or carry an OSC payload). The SGR-only test
186
- gates on both the 7-bit ESC (`U+001B`) introducer and the whole 8-bit C1
187
- control block (U+0080–U+009F)—not just the CSI byte (`U+009B`)—so a
188
- C1-introduced cursor-move, erase, or OSC/DCS/SOS/PM/APC string is never
189
- mistaken for benign color.
192
+ One carve-out: a prompt whose only escape content is INERT passes with a note —
193
+ display-only SGR color, and/or a 7-bit `ESC` that completes no sequence (a log
194
+ line cut mid-escape). Pasting colored terminal output is the common case, and
195
+ neither form can move the cursor, erase, or carry an OSC payload. The test gates
196
+ on both the 7-bit ESC (`U+001B`) introducer and the whole 8-bit C1 control block
197
+ (U+0080–U+009F)—not just the CSI byte (`U+009B`)—so a C1-introduced cursor-move,
198
+ erase, or OSC/DCS/SOS/PM/APC string is never mistaken for benign color; and it
199
+ judges from what the Layer-1 strip actually removed, so a sequence that only
200
+ RECONSTITUTES during stripping is judged as the sequence it becomes.
190
201
 
191
202
  ## Tool-output pipeline & Layer 5
192
203
 
@@ -17,6 +17,7 @@ import {
17
17
  probeSetupAlive,
18
18
  readStdinJson,
19
19
  } from "./hook-io.mjs";
20
+ import { startHookTimer, withSlowHookNotice } from "./hook-timing.mjs";
20
21
 
21
22
  // Loaded via a *caught* dynamic import — never a bare static `import … from`.
22
23
  // A static npm import resolves before any try/catch, so a missing node_modules
@@ -128,7 +129,12 @@ export function nativeStdout(response) {
128
129
  * unparsable stdin, missing package, a judge error — is reported on stderr and
129
130
  * routed to `onError(err, input)` (`input` undefined when stdin never parsed),
130
131
  * where the hook applies its declared fail posture.
131
- * @param {string} hookName prefix for the stderr diagnostic
132
+ *
133
+ * It is also where every judge hook is TIMED: the verdict picks up a
134
+ * performance note when the judge overran the hook budget (see
135
+ * lib/hook-timing.mjs), so no hook has to remember to measure itself.
136
+ * @param {string} hookName prefix for the stderr diagnostic, and the hook name
137
+ * a slow-run notice reports
132
138
  * @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
133
139
  * import("agent-control-plane-core").Verdict |
134
140
  * Promise<import("agent-control-plane-core").Verdict>} judge
@@ -152,9 +158,18 @@ export async function runJudgeCli(
152
158
  let input;
153
159
  try {
154
160
  input = await readInput();
161
+ // Timed from HERE, not from process start: the wait for the harness to hand
162
+ // over stdin is not this hook's cost, and blaming it for one would send
163
+ // operators chasing a bug report that is not theirs to fix.
164
+ const elapsed = startHookTimer();
155
165
  const { claudeAdapter: adapter } = controlPlane();
156
166
  const event = adapter.parse(transformInput(input));
157
- const out = nativeStdout(adapter.render(await judge(event), event));
167
+ // Awaited into its own binding first: as an inline argument, `elapsed()`
168
+ // would be evaluated BEFORE the judge it is supposed to be timing.
169
+ const judged = await judge(event);
170
+ const out = nativeStdout(
171
+ adapter.render(withSlowHookNotice(hookName, elapsed(), judged), event),
172
+ );
158
173
  if (out !== null) write(out);
159
174
  } catch (err) {
160
175
  process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
@@ -11,6 +11,7 @@ import {
11
11
  import { userInfo } from "node:os";
12
12
  import { createHash } from "node:crypto";
13
13
  import { pathToFileURL } from "node:url";
14
+ import { excludeProvisioning } from "./hook-timing.mjs";
14
15
 
15
16
  /**
16
17
  * EVERY process-wide slot these helpers keep — the four a host can observe or
@@ -742,6 +743,44 @@ export async function awaitLazyDependency({
742
743
  settleMs = 1000,
743
744
  ceilingMs = 900000,
744
745
  intervalMs = 250,
746
+ }) {
747
+ // The whole wait is PROVISIONING, not this hook's own cost: it is time the
748
+ // container spends installing dependencies, and on a cold start it can run to
749
+ // minutes. Charging it to the hook would make the slow-hook notice fire on
750
+ // every cold session with a number that names the wrong culprit.
751
+ return excludeProvisioning(
752
+ () =>
753
+ pollForDependency({
754
+ tryImport,
755
+ markerPresent,
756
+ setupAlive,
757
+ now,
758
+ sleep,
759
+ graceMs,
760
+ settleMs,
761
+ ceilingMs,
762
+ intervalMs,
763
+ }),
764
+ now,
765
+ );
766
+ }
767
+
768
+ /**
769
+ * The poll loop {@link awaitLazyDependency} wraps. Split out so the
770
+ * provisioning charge brackets every exit — including the give-up arms.
771
+ * @param {Required<Parameters<typeof awaitLazyDependency>[0]>} opts
772
+ * @returns {Promise<Record<string, any> | null>}
773
+ */
774
+ async function pollForDependency({
775
+ tryImport,
776
+ markerPresent,
777
+ setupAlive,
778
+ now,
779
+ sleep,
780
+ graceMs,
781
+ settleMs,
782
+ ceilingMs,
783
+ intervalMs,
745
784
  }) {
746
785
  const start = now();
747
786
  let sawInstalling = false;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The one place a hook's own wall-clock cost is measured and reported.
3
+ *
4
+ * These hooks sit on the critical path of every tool call, every prompt and
5
+ * every session start: whatever they spend, the user waits. That cost is also
6
+ * the hardest kind of bug to notice from inside — a hook that got slow looks
7
+ * exactly like an agent that got slow, so it goes unreported for weeks (one
8
+ * SessionStart scan blocked startup for 30 SECONDS before anyone traced it back
9
+ * here). A hook past the budget therefore says so IN BAND, in the model's
10
+ * context, where it cannot be missed and can be relayed to the operator.
11
+ *
12
+ * One threshold, one message, one merge rule, shared by every hook — the
13
+ * measurement is worthless if each hook words it differently or picks its own
14
+ * bar for "slow".
15
+ *
16
+ * What it deliberately does NOT count is ONE-TIME PROVISIONING (see
17
+ * {@link excludeProvisioning}). A dependency-install wait or a cold redactor
18
+ * spawn is wall-clock the user really waits, but it is not a cost this hook
19
+ * pays per call and it is not a bug worth a report — charging it would make the
20
+ * FIRST call of every session cry wolf, which is precisely the alert fatigue
21
+ * this notice exists to avoid.
22
+ *
23
+ * Dependency-free on purpose: everything imports this, including hook-io, so a
24
+ * back-import would close a cycle. The one emitter it needs is passed in.
25
+ */
26
+
27
+ /**
28
+ * Wall-clock a single hook invocation may spend before it is reported as slow.
29
+ *
30
+ * A second is far above anything these hooks do when healthy (Layer 1 is a few
31
+ * regex passes; the redactor daemon answers in tens of milliseconds once warm)
32
+ * and far below the point where a human is merely impatient — so crossing it
33
+ * means something is actually wrong, not that the machine is busy.
34
+ */
35
+ export const SLOW_HOOK_THRESHOLD_MS = 1000;
36
+
37
+ /** Where a reader is asked to send the timing. */
38
+ const ISSUE_URL =
39
+ "https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
40
+
41
+ // Process-wide total of milliseconds spent in one-time provisioning. A running
42
+ // total rather than a flag because a single hook run can pay more than one (a
43
+ // dependency wait AND a cold daemon spawn), and they may not nest.
44
+ let provisioningMs = 0;
45
+
46
+ /**
47
+ * Run `work`, charging its whole duration to provisioning so no timer running
48
+ * across it counts that time. Charged in a `finally`, so a provisioning step
49
+ * that FAILS is still excluded — the wait happened either way, and a hook that
50
+ * then fails is reported through its fault posture, not as "slow".
51
+ *
52
+ * Wrap only genuinely one-time, per-session setup: waiting out a dependency
53
+ * install, waiting for a cold redactor daemon to bind. Never wrap the hook's
54
+ * actual work — that is exactly what this measurement is for.
55
+ * @template T
56
+ * @param {() => Promise<T>} work
57
+ * @param {() => number} [now] injectable clock, for tests
58
+ * @returns {Promise<T>}
59
+ */
60
+ export async function excludeProvisioning(work, now = Date.now) {
61
+ const started = now();
62
+ try {
63
+ return await work();
64
+ } finally {
65
+ provisioningMs += Math.max(0, now() - started);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Start measuring; the returned function reports the milliseconds elapsed so
71
+ * far MINUS any provisioning charged in the meantime, and may be called more
72
+ * than once.
73
+ *
74
+ * Only provisioning charged since this timer started is subtracted, so an
75
+ * earlier run's cold start cannot pay down a later run's real cost. A
76
+ * provisioning window that straddles the timer's start would otherwise be able
77
+ * to subtract more than the timer has measured, so the result is floored at 0.
78
+ * @param {() => number} [now] injectable clock, for tests
79
+ * @returns {() => number}
80
+ */
81
+ export function startHookTimer(now = Date.now) {
82
+ const started = now();
83
+ const provisionedBefore = provisioningMs;
84
+ return () =>
85
+ Math.max(0, now() - started - (provisioningMs - provisionedBefore));
86
+ }
87
+
88
+ /**
89
+ * The model-facing line for a hook that overran the budget, or null when it did
90
+ * not. Addressed to the model because the model is the only party that reliably
91
+ * reads this channel — stderr from a non-blocking hook is easy to miss — and it
92
+ * is asked to relay the number, since the operator is the one who can file it.
93
+ * @param {string} hookName
94
+ * @param {number} elapsedMs
95
+ * @param {number} [thresholdMs]
96
+ * @returns {string | null}
97
+ */
98
+ export function slowHookNotice(
99
+ hookName,
100
+ elapsedMs,
101
+ thresholdMs = SLOW_HOOK_THRESHOLD_MS,
102
+ ) {
103
+ if (elapsedMs <= thresholdMs) return null;
104
+ return (
105
+ `agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
106
+ `${(elapsedMs / 1000).toFixed(1)}s, over its ${(thresholdMs / 1000).toFixed(1)}s budget — ` +
107
+ "this delay is the sanitizer's, not the model's, and every affected call pays it. " +
108
+ `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and timing.`
109
+ );
110
+ }
111
+
112
+ /**
113
+ * `verdict` with the slow-hook notice folded into its `additional_context`, or
114
+ * the verdict untouched when the run was within budget. Also writes the notice
115
+ * to stderr, so the timing survives in the transcript even for a hook whose
116
+ * verdict carries no context channel to the model.
117
+ *
118
+ * Appended, never substituted: the context slot is how a hook reports a REDACTED
119
+ * secret or a stripped payload, and a timing note must not displace that.
120
+ * @template {{ additional_context?: string }} V
121
+ * @param {string} hookName
122
+ * @param {number} elapsedMs
123
+ * @param {V} verdict
124
+ * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
125
+ * @returns {V}
126
+ */
127
+ export function withSlowHookNotice(
128
+ hookName,
129
+ elapsedMs,
130
+ verdict,
131
+ writeErr = (chunk) => process.stderr.write(chunk),
132
+ ) {
133
+ const notice = slowHookNotice(hookName, elapsedMs);
134
+ if (notice === null) return verdict;
135
+ writeErr(notice + "\n");
136
+ return {
137
+ ...verdict,
138
+ additional_context: verdict.additional_context
139
+ ? `${verdict.additional_context} ${notice}`
140
+ : notice,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Report a slow run for a hook that answers with a bare `hookSpecificOutput`
146
+ * envelope rather than a control-plane verdict — SessionStart, which has no
147
+ * verdict channel at all. A within-budget run emits nothing, so the quiet path
148
+ * stays quiet (and the hook's silent-success contract is unchanged).
149
+ * @param {string} hookName
150
+ * @param {number} elapsedMs
151
+ * @param {string} hookEventName
152
+ * @param {(event: string, fields: Record<string, unknown>) => void} emit the
153
+ * stdout envelope writer (hook-io's emitHookResponse); passed in rather than
154
+ * imported so this module stays dependency-free — see the module doc
155
+ * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
156
+ * @returns {boolean} whether a notice was emitted
157
+ */
158
+ export function reportSlowHook(
159
+ hookName,
160
+ elapsedMs,
161
+ hookEventName,
162
+ emit,
163
+ writeErr = (chunk) => process.stderr.write(chunk),
164
+ ) {
165
+ const notice = slowHookNotice(hookName, elapsedMs);
166
+ if (notice === null) return false;
167
+ writeErr(notice + "\n");
168
+ emit(hookEventName, { additionalContext: notice });
169
+ return true;
170
+ }
@@ -18,6 +18,7 @@
18
18
  * daemon could not vet input.
19
19
  */
20
20
  import { spawn } from "node:child_process";
21
+ import { excludeProvisioning } from "./hook-timing.mjs";
21
22
  import { existsSync, lstatSync } from "node:fs";
22
23
  import { createConnection } from "node:net";
23
24
  import { tmpdir, userInfo } from "node:os";
@@ -422,7 +423,7 @@ function canConnect(socketPath) {
422
423
  * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
423
424
  * deadline?: {remainingMs: () => number},
424
425
  * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
425
- * waitForSocket?: typeof waitForSocket}} [opts]
426
+ * waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
426
427
  * @returns {Promise<RedactResponse|null>}
427
428
  */
428
429
  export async function redactViaDaemon(text, opts = {}) {
@@ -434,6 +435,10 @@ export async function redactViaDaemon(text, opts = {}) {
434
435
  connect = connectAndRequest,
435
436
  spawn: spawnFn = spawnDaemon,
436
437
  waitForSocket: waitFn = waitForSocket,
438
+ // The clock the provisioning charge is measured on; injectable alongside the
439
+ // waitForSocket seam it brackets, since a stubbed wait advances a test clock
440
+ // rather than real time.
441
+ now = Date.now,
437
442
  } = opts;
438
443
  // Remaining shared budget in ms, or undefined when no budget was threaded (the
439
444
  // standalone default). Re-read per step so the respawn path cannot overshoot.
@@ -502,7 +507,11 @@ export async function redactViaDaemon(text, opts = {}) {
502
507
  budgetMs === undefined
503
508
  ? undefined
504
509
  : { deadlineMs: Math.min(WAIT_DEADLINE_MS, budgetMs) };
505
- if (!(await waitFn(socketPath, waitOpts)))
510
+ // The cold-start wait is PROVISIONING — a one-time detect-secrets import and
511
+ // plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
512
+ // happens to be first and by no other. Charging it to this hook would make
513
+ // the slow-hook notice fire once per session on a healthy install.
514
+ if (!(await excludeProvisioning(() => waitFn(socketPath, waitOpts), now)))
506
515
  throw failClosed(
507
516
  new Error(`redactor daemon did not start within ${WAIT_DEADLINE_MS}ms`),
508
517
  );
@@ -96,13 +96,18 @@ const SANITIZE_BUDGET_MS = positiveMsOr(
96
96
  120000,
97
97
  );
98
98
 
99
- // Non-WARNING note for a strip whose only change was display-only SGR color on a
100
- // local tool: cosmetic styling git/pytest/npm/etc. emit by default. It keeps the
101
- // "color was here, and here is how to see it" signal without the WARNING prefix,
102
- // whose constant firing on benign color would desensitize the reader to the
103
- // strips that matter (invisible-char payloads, redacted secrets).
99
+ // Non-WARNING note for a strip whose only change was INERT ANSI on a local tool:
100
+ // the display-only colour git/pytest/npm/etc. emit by default, and/or a stray
101
+ // escape byte that formed no sequence at all (a truncated write, a log fragment,
102
+ // a raw ESC sitting in a file the tool echoed back). Neither can move the cursor,
103
+ // erase the screen, or open an OSC string — those need a complete CSI/OSC token,
104
+ // which keeps the WARNING. The note keeps the "escapes were here, and here is how
105
+ // to see them" signal without the WARNING prefix, whose constant firing on inert
106
+ // bytes would desensitize the reader to the strips that matter (invisible-char
107
+ // payloads, redacted secrets).
104
108
  const SGR_OUTPUT_NOTE =
105
- "Display-only ANSI color stripped; pipe through cat -v to inspect raw escapes.";
109
+ "Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
110
+ "formed no control sequence); pipe through cat -v to inspect raw escapes.";
106
111
 
107
112
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
108
113
  // Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
@@ -65,7 +65,7 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
65
65
  blockContext:
66
66
  "User prompt blocked: payload-capable invisible/ANSI characters detected.",
67
67
  sgrNote:
68
- "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
68
+ "The prompt contains inert ANSI escapes (pasted terminal output): display-only SGR colour codes and/or a stray escape byte that forms no control sequence. They are formatting noise; read through them.",
69
69
  hookFailed: (cause) =>
70
70
  `sanitize-user-prompt hook failed (fail-closed): ${cause}`,
71
71
  // What a reader should run when the package itself is what is missing. It
@@ -9,6 +9,7 @@ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
10
  import {
11
11
  awaitLazyDependency,
12
+ emitHookResponse,
12
13
  safeErrMessage,
13
14
  hookgateMarkerPath,
14
15
  HookEvent,
@@ -29,6 +30,7 @@ import {
29
30
  PROJECT_DIR,
30
31
  } from "./lib/invisible-alert.mjs";
31
32
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
33
+ import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
32
34
 
33
35
  // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
34
36
  // hazard of a bare static npm import — here the instruction files would load
@@ -191,27 +193,85 @@ function decodeRun(run) {
191
193
  }
192
194
 
193
195
  /**
194
- * @param {string} dir
195
- * @returns {string[]}
196
+ * The `.claude/` subdirectories whose markdown Claude Code loads as model
197
+ * context. This is a WHITELIST, and that is the point: `.claude/` is also where
198
+ * tooling parks bulk data that is never loaded as context — `worktrees/`
199
+ * (entire checked-out copies of the repo), plus caches, transcripts and
200
+ * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
201
+ * populated worktrees that is thousands of files READ at every session start:
202
+ * one report put it at 30 seconds of blocked startup, paid for scanning files
203
+ * that cannot reach the model.
204
+ *
205
+ * A whitelist, not a `worktrees` denylist, because the failure modes are not
206
+ * symmetric: an unlisted context directory costs a scan this hook was never
207
+ * asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
208
+ * tool reads them), while an unlisted BULK directory silently costs every future
209
+ * session its startup. Add an entry here when Claude Code starts loading a new
210
+ * `.claude/` subdirectory as context.
211
+ */
212
+ export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
213
+ "agents",
214
+ "commands",
215
+ "output-styles",
216
+ "skills",
217
+ ]);
218
+
219
+ // The glob patterns for one `.claude` tree at `prefix` (empty for the project
220
+ // root, a doubled-star segment for nested ones): its top-level markdown, plus the
221
+ // whitelisted context subdirectories. Built once, from the one list above.
222
+ /** @param {string} prefix @returns {string[]} */
223
+ function claudeDirPatterns(prefix) {
224
+ return [
225
+ `${prefix}.claude/*.md`,
226
+ ...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${prefix}.claude/${sub}/**/*.md`),
227
+ ];
228
+ }
229
+
230
+ /**
231
+ * Entries the walk must not descend into or return: `node_modules`, and every
232
+ * child of a `.claude` directory that is not whitelisted context.
233
+ *
234
+ * The patterns alone would already refuse to MATCH those files, but globSync
235
+ * calls this on directories as it walks and prunes the ones it rejects — which
236
+ * is where the cost actually is. Without the prune, a `.claude/worktrees/`
237
+ * holding a few repo checkouts is walked in full on every session start (and,
238
+ * because a doubled-star segment does cross into a dot directory when the
239
+ * pattern names one, a `.claude` NESTED inside a worktree was matched and
240
+ * scanned as if it were this session's context).
241
+ *
242
+ * globSync calls this with both bare names and repo-relative paths, so it must
243
+ * answer for either; a bare name carries no `.claude` context and is judged only
244
+ * against `node_modules`.
245
+ * @param {string} entry a bare entry name or a path relative to the scan root
246
+ * @returns {boolean}
196
247
  */
197
- function findMdFiles(dir) {
198
- return globSync("**/*.md", {
199
- cwd: dir,
200
- exclude: (name) => name === "node_modules",
201
- }).map((name) => join(dir, name));
248
+ function excludeFromScan(entry) {
249
+ if (entry === "node_modules") return true;
250
+ const parts = entry.split(/[/\\]/);
251
+ const claudeIndex = parts.indexOf(".claude");
252
+ const tail = parts.slice(claudeIndex + 1);
253
+ if (claudeIndex === -1 || tail.length === 0) return false;
254
+ // `.claude/<file>.md` is context (a top-level note); anything else directly
255
+ // under `.claude` must be a whitelisted subdirectory to be walked at all.
256
+ if (tail.length === 1 && tail[0].endsWith(".md")) return false;
257
+ return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
202
258
  }
203
259
 
204
260
  /**
205
- * Every subdirectory instruction file (CLAUDE.md, CLAUDE.local.md, AGENTS.md)
206
- * under `dir`. Claude Code loads these as project instructions on entry to their
207
- * containing directory a load path that bypasses the PostToolUse sanitizer — so
208
- * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
209
- * unless it is scanned here. Skips node_modules.
261
+ * Every file under `dir` that Claude Code loads as model context: the
262
+ * subdirectory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and the
263
+ * whitelisted `.claude/` markdown (see {@link CLAUDE_CONTEXT_SUBDIRS}). Claude
264
+ * Code loads these on entry to their containing directory — a load path that
265
+ * bypasses the PostToolUse sanitizer so a payload planted in e.g.
266
+ * `packages/foo/CLAUDE.md` reaches the model uncleaned unless it is scanned
267
+ * here. Skips node_modules.
210
268
  *
211
269
  * `**` does not descend into dot directories, so NESTED `.claude/` trees need
212
- * their own pattern: the caller scans only the project-root `.claude`, which
213
- * would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
214
- * — model context by the same load path never scanned.
270
+ * their own doubled-star-prefixed patterns: without them a directory-scoped skill at
271
+ * `packages/foo/.claude/skills/x/SKILL.md` — model context by the same load
272
+ * path is never scanned. That same rule is why the root `.claude` needs no
273
+ * separate walk: a leading doubled star matches zero segments, so the nested
274
+ * patterns cover the root tree too.
215
275
  * @param {string} dir
216
276
  * @returns {string[]}
217
277
  */
@@ -221,12 +281,9 @@ function findInstructionFiles(dir) {
221
281
  "**/CLAUDE.md",
222
282
  "**/CLAUDE.local.md",
223
283
  "**/AGENTS.md",
224
- "**/.claude/**/*.md",
284
+ ...claudeDirPatterns("**/"),
225
285
  ],
226
- {
227
- cwd: dir,
228
- exclude: (name) => name === "node_modules",
229
- },
286
+ { cwd: dir, exclude: excludeFromScan },
230
287
  ).map((name) => join(dir, name));
231
288
  }
232
289
 
@@ -268,7 +325,6 @@ function scanFile(filePath) {
268
325
 
269
326
  export {
270
327
  decodeRun,
271
- findMdFiles,
272
328
  findInstructionFiles,
273
329
  scanFile,
274
330
  ALERT_FILE,
@@ -350,12 +406,7 @@ export { formatReport };
350
406
  * }}
351
407
  */
352
408
  export function scanProject(dir = PROJECT_DIR) {
353
- const targets = [
354
- ...new Set([
355
- ...findInstructionFiles(dir),
356
- ...findMdFiles(join(dir, ".claude")),
357
- ]),
358
- ];
409
+ const targets = [...new Set(findInstructionFiles(dir))];
359
410
  const findings = [];
360
411
  const skipped = [];
361
412
  let scanned = 0;
@@ -420,7 +471,35 @@ export function formatSkipped(skipped) {
420
471
  * untested fault path is how a posture goes missing in the first place.
421
472
  * @returns {Promise<void>}
422
473
  */
423
- export async function cliMain({ trace: sink = trace, scan: runScan } = {}) {
474
+ export async function cliMain(opts = {}) {
475
+ // The scan blocks session startup, and a slow one is invisible from the
476
+ // inside — it reads as "Claude is slow to start". Timing the whole body and
477
+ // reporting an overrun in band is what turned a 30-second scan from a rumor
478
+ // into a bug report (see lib/hook-timing.mjs).
479
+ const elapsed = startHookTimer();
480
+ try {
481
+ await runScanCli(opts);
482
+ } finally {
483
+ reportSlowHook(
484
+ HOOK_NAME,
485
+ elapsed(),
486
+ HookEvent.SESSION_START,
487
+ emitHookResponse,
488
+ );
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The scan itself. Split from {@link cliMain} only so the timing wrapper above
494
+ * has a single call to bracket — every early return here is an exit the wrapper
495
+ * must still measure.
496
+ * @param {{
497
+ * trace?: import("./lib/trace.mjs").TraceFn,
498
+ * scan?: () => ReturnType<typeof scanProject>,
499
+ * }} opts see {@link cliMain}
500
+ * @returns {Promise<void>}
501
+ */
502
+ async function runScanCli({ trace: sink = trace, scan: runScan }) {
424
503
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
425
504
  // the alert write, with no catch above them, so a throwing host sink would
426
505
  // abort the scan silently (see bestEffortTrace).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.23.2",
3
+ "version": "2.24.1",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {