agent-sanitizer 2.37.0 → 2.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/THREAT-MODEL.md CHANGED
@@ -282,21 +282,49 @@ threshold. `cleanFile` strips the payload in place (Layer-1 strip), failing loud
282
282
  if a contaminated file cannot be rewritten.
283
283
 
284
284
  As Claude Code hooks the coverage is split to match how Claude Code loads these
285
- files. `scan-invisible-chars` (SessionStart) scans the launch set the project
286
- root's own instruction files, the `CLAUDE.md` chain above it, and the root
287
- `.claude/` context subdirectories — and `scan-loaded-instructions`
288
- (InstructionsLoaded) scans every other instruction file including the
289
- user-global `~/.claude` memory and rules, which load into every session on the
290
- machine reading the one path the event names, at the moment it loads. The second cannot block: the file is already in
291
- context when it fires, so its neutralization is to strip the payload from disk
292
- (so no reload re-reads it) and tell the model to treat what it just read as
293
- untrusted data. Auto-cleaning is confined to `CLAUDE_PROJECT_DIR` in both — an
294
- ancestor file, or one under `~/.claude`, is shared with every other project on
295
- the machine, so it is reported through the cross-hook alert and never
296
- rewritten. A Claude Code build that emits
285
+ files, and `src/claude-context.mjs` is where the split is defined: one table of
286
+ context KINDS, each row naming what loads it and when.
287
+
288
+ - **SessionStart** (`scan-invisible-chars`) scans the launch setthe project
289
+ root's own instruction files, the `CLAUDE.md` chain above it, and the root
290
+ `.claude/` context subdirectories. O(directory depth), not O(tree).
291
+ - **InstructionsLoaded** (`scan-loaded-instructions`) scans each file the host
292
+ names as it loads it: the rows marked `eventNamed` the `CLAUDE.md` family
293
+ and `.claude/rules` wherever they sit, including the user-global `~/.claude`
294
+ memory and rules that load into every session on the machine.
295
+ - **Everything else** a nested `AGENTS.md`, a nested `.claude/` skill,
296
+ command or output-style is covered on demand by the whole-tree
297
+ `CLAUDE_INSTRUCTION_GLOBS` scan (what the CLI walks) and by the PostToolUse
298
+ sanitizer when a tool reads one. Covering those eagerly means the whole-tree
299
+ walk at session start that this split exists to remove.
300
+
301
+ The lazy half cannot block: the file is already in context when it fires, so its
302
+ neutralization is to strip the payload from disk (so no reload re-reads it) and
303
+ tell the model to treat what it just read as untrusted data. Auto-cleaning is
304
+ confined to `CLAUDE_PROJECT_DIR` in both — an ancestor file, or one under
305
+ `~/.claude`, is shared with every other project on the machine, so it is reported
306
+ through the cross-hook alert and never rewritten. A Claude Code build that emits
297
307
  no `InstructionsLoaded` event loses the lazy half entirely; the PreToolUse gate
298
308
  says so once per session rather than leaving the gap silent.
299
309
 
310
+ That table is a claim about someone else's product, so the event that names a
311
+ loaded file is also what falsifies it. `contextScopeContradiction` checks every
312
+ path the hook is handed and reports two observations: context loading out of a
313
+ `.claude/` subdirectory the whitelist does not carry (the launch scan prunes
314
+ that directory, so every other file in it is unscanned), and a kind marked
315
+ event-blind arriving through the event anyway (the lazy scan reaches further
316
+ than the table, and this section, credit it with). Everything else is silent, in
317
+ both directions: a path the table does not name — an `@import` of arbitrary
318
+ markdown — reports nothing rather than guessing, and a directory it names as
319
+ storage (`worktrees/`, `projects/`) reports nothing because whitelisting storage
320
+ is the whole-tree walk again. Both observations also require a `load_reason` the
321
+ host chose itself: an `@import` names a file the user's own markdown pointed at,
322
+ which says nothing about what a scan would reach on its own. The
323
+ notice never widens the scan on its own — one load cannot tell a context
324
+ directory from an import target, and whitelisting the wrong one buys back the
325
+ startup cost this split removed — so the whitelist stays hand-maintained and the
326
+ notice's job is to put a stale entry in front of a person.
327
+
300
328
  ## User-prompt verdict
301
329
 
302
330
  `./prompt` classifies a submitted prompt as **pass / pass-with-note / block** on
@@ -2,17 +2,14 @@
2
2
  * InstructionsLoaded: scan an instruction file for hidden-Unicode injection at
3
3
  * the moment Claude Code loads it into context.
4
4
  *
5
- * This is the lazy half of the instruction-file scan. Claude Code loads a
6
- * subdirectory's CLAUDE.md (and a path-scoped rule, an `@import`, a
7
- * post-compaction reload) only when it needs it, and this event names the file
8
- * it just loaded so the scan costs one read and one `scanText` on exactly the
9
- * file that entered context, with no glob and no walk. The SessionStart scan
10
- * covers what loads at launch from the project root and its parents; everything
11
- * else arrives here, including the user-global `~/.claude` memory and rules that
12
- * load into every session on the machine a second root that would otherwise
13
- * need its own walk at every startup. Between them the coverage is wider than
14
- * the whole-tree walk they replace, which could not see a file created after it
15
- * ran, a rule outside the project, or the CLAUDE.md chain above it.
5
+ * This is the lazy half of the instruction-file scan: the event names the file
6
+ * it just loaded, so the scan costs one read and one `scanText` on exactly the
7
+ * file that entered context, with no glob and no walk. SessionStart covers what
8
+ * loads at launch from the project root and its parents; the kinds
9
+ * `src/claude-context.mjs` marks `eventNamed` arrive here, the user-global
10
+ * `~/.claude` memory and rules among them a second root that would otherwise
11
+ * need its own walk at every startup. Naming a file is also the only way the
12
+ * host can prove that table wrong, which is what {@link scopeNotice} reports.
16
13
  *
17
14
  * The event CANNOT block: its exit code is ignored and the bytes are already in
18
15
  * context by the time it fires. What it can do is exactly what SessionStart does
@@ -42,7 +39,10 @@ import {
42
39
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
43
40
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
44
41
  import { formatReport } from "./lib/invisible-report.mjs";
45
- import { isInsideDir } from "../src/claude-context.mjs";
42
+ import {
43
+ contextScopeContradiction,
44
+ isInsideDir,
45
+ } from "../src/claude-context.mjs";
46
46
 
47
47
  // The instruction-scanner SSOT, bound via lazyImport (see its doc for the
48
48
  // fail-OPEN hazard of a bare static npm import — here a loaded instruction file
@@ -119,8 +119,8 @@ export function readLoadedFile(payload) {
119
119
  );
120
120
  return {
121
121
  filePath,
122
- // Metadata for the trace channel only, so an unknown/absent reason is a
123
- // label, never a reason to skip the scan.
122
+ // Labelled, never missing: the trace channel and the scope notice both read
123
+ // it, and an unknown reason must not read as a reason to skip the scan.
124
124
  loadReason: typeof loadReason === "string" ? loadReason : "unknown",
125
125
  };
126
126
  }
@@ -175,6 +175,26 @@ export function scanLoadedFile(
175
175
  }
176
176
  }
177
177
 
178
+ /**
179
+ * The operator-facing line for a file whose path contradicts the scope table, or
180
+ * null when it does not. This hook is where that check belongs and the only
181
+ * place it can run: the host naming a file as it loads is the one observation
182
+ * that can prove the SessionStart scan's scope wrong, and a scope that is wrong
183
+ * about a `.claude/` subdirectory is a launch scan with a hole in it.
184
+ *
185
+ * Separate from the finding channels below: this is a maintenance signal about
186
+ * THIS package, not a verdict about the file, so it never reaches the model and
187
+ * never arms the tool-call gate.
188
+ * @param {string} filePath
189
+ * @param {string} loadReason why the host loaded it, which decides whether the
190
+ * load is evidence about the scan's scope at all
191
+ * @returns {string | null}
192
+ */
193
+ export function scopeNotice(filePath, loadReason) {
194
+ const stale = contextScopeContradiction(filePath, loadReason);
195
+ return stale && `${HOOK_NAME} scope notice: ${stale}.`;
196
+ }
197
+
178
198
  /**
179
199
  * The operator- and model-facing text for a scanned file. Both channels carry
180
200
  * it: the bytes are already in context, so the model is told to distrust what it
@@ -221,6 +241,10 @@ export async function cliMain({ trace: sink = trace } = {}) {
221
241
  // before the payload's OWN fields are validated for the same reason.
222
242
  recordInstructionsLoaded(payload?.session_id);
223
243
  const loaded = readLoadedFile(payload);
244
+ // Before the scan: a file this hook cannot read still told us where the host
245
+ // loads context from, and that is the half the scope table needs.
246
+ const notice = scopeNotice(loaded.filePath, loaded.loadReason);
247
+ if (notice) process.stderr.write(notice + "\n");
224
248
  const result = scanLoadedFile(loaded.filePath);
225
249
  if (result === null) {
226
250
  emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.37.0",
3
+ "version": "2.37.2",
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
2
+ "_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
3
3
  "unicode_version": "17.0",
4
4
  "extra_codepoints": [
5
5
  847,
@@ -477,5 +477,6 @@
477
477
  157,
478
478
  158,
479
479
  159
480
- ]
480
+ ],
481
+ "escape_sequence_pattern": "(?:(?:\\u001b[\\u005d\\u0050\\u0058\\u005e\\u005f]|[\\u0090\\u0098\\u009d\\u009e\\u009f])[^\\u0007\\u000a\\u000d\\u0018\\u001a\\u001b\\u0090\\u0098\\u009c\\u009d\\u009e\\u009f]*(?:[\\u0007\\u0018\\u001a\\u009c]|\\u001b\\u005c|(?=[\\u000a\\u000d\\u001b\\u0090\\u0098\\u009d\\u009e\\u009f])|(?![\\s\\S]))|[\\u001b\\u009b][\\[()#;?]*(?![\\[()#;?])[0-9;:]*[A-PR-TZcf-nqrty~])"
481
482
  }
package/src/ansi.mjs CHANGED
@@ -43,9 +43,25 @@ export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
43
43
  // grep-based drift check as well. Derived from the code-point list above so the
44
44
  // regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
45
45
  // control byte out of the source (no `no-control-regex` disable needed).
46
- export const CONTROL_INTRODUCER_SOURCE = `[${CONTROL_INTRODUCER_CODEPOINTS.map(
47
- (cp) => `\\u${cp.toString(16).padStart(4, "0")}`,
48
- ).join("")}]`;
46
+ export const CONTROL_INTRODUCER_SOURCE = charClass(
47
+ CONTROL_INTRODUCER_CODEPOINTS,
48
+ );
49
+
50
+ /** A code point as a `\uXXXX` escape — the one spelling of a control byte that
51
+ * both this module's regexes and the generated Python pattern use, so no raw
52
+ * control byte ever lands in either source.
53
+ * @param {number} cp
54
+ * @returns {string} */
55
+ function unicodeEscape(cp) {
56
+ return `\\u${cp.toString(16).padStart(4, "0")}`;
57
+ }
58
+
59
+ /** A character class matching exactly the given code points.
60
+ * @param {readonly number[]} cps
61
+ * @returns {string} */
62
+ function charClass(cps) {
63
+ return `[${cps.map(unicodeEscape).join("")}]`;
64
+ }
49
65
 
50
66
  // SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
51
67
  // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
@@ -73,10 +89,15 @@ const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
73
89
  // Private parameter-prefix and intermediate bytes that may follow an
74
90
  // introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
75
91
  // the 7-bit `ESC [` CSI introducer's bracket itself.
76
- const CSI_INTRO_RE = /[[()#;?]/;
92
+ // The `[` is escaped though neither engine requires it: Python's `re` warns
93
+ // `FutureWarning: Possible nested set` on a bare one, and this class ships as
94
+ // the generated pattern a Python consumer compiles.
95
+ const CSI_INTRO_CLASS = "[\\[()#;?]";
96
+ const CSI_INTRO_RE = new RegExp(CSI_INTRO_CLASS);
77
97
 
78
98
  // ECMA-48 parameter bytes.
79
- const CSI_PARAM_RE = /[0-9;:]/;
99
+ const CSI_PARAM_CLASS = "[0-9;:]";
100
+ const CSI_PARAM_RE = new RegExp(CSI_PARAM_CLASS);
80
101
 
81
102
  // ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
82
103
  // PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
@@ -86,15 +107,26 @@ const CSI_PARAM_RE = /[0-9;:]/;
86
107
  // PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
87
108
  // private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
88
109
  // final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
89
- const CSI_FINAL_RE = /[A-PR-TZcf-nqrty~]/;
110
+ const CSI_FINAL_CLASS = "[A-PR-TZcf-nqrty~]";
111
+ const CSI_FINAL_RE = new RegExp(CSI_FINAL_CLASS);
90
112
 
91
113
  const ESC = 0x1b;
92
114
  const CSI_C1 = 0x9b;
93
115
  const ST_C1 = 0x9c;
94
116
  const BEL = 0x07;
95
- // CAN/SUB cancel a control string per ECMA-48 and the xterm parser; LF/CR do
96
- // not, but bound the body anyway as a fail-closed blast-radius limit (see
97
- // scanControlString).
117
+ // THE ABORT SET the four controls that end a control string short of its
118
+ // terminator, and the whole of it. Every other C0 control and DEL is
119
+ // deliberately consumed as body, because that is what a terminal does with
120
+ // them: DEC's parser (vt100.net/emu/dec_ansi_parser) IGNORES C0 other than
121
+ // CAN/SUB/ESC in `osc_string` and `sos_pm_apc_string` and `put`s them in
122
+ // `dcs_passthrough`, so aborting on `VT`/`FF`/`NUL`/`DEL` would end the token
123
+ // early and splice the rest of a payload the terminal swallows back into the
124
+ // model's view — the under-strip this layer exists to close.
125
+ // CAN/SUB — ECMA-48 and that same parser cancel the string here.
126
+ // LF/CR — NOT terminal behavior, a fail-closed blast-radius limit: they
127
+ // are the only two controls that cross a line, and a body running
128
+ // past one blinds a reader who consumes the strip as a RECORD
129
+ // rather than rendering it (see scanControlString).
98
130
  const CAN = 0x18;
99
131
  const SUB = 0x1a;
100
132
  const LF = 0x0a;
@@ -120,6 +152,59 @@ const OSC_C1 = 0x9d;
120
152
  // set: opening a string is exactly what ends the one already open.
121
153
  const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
122
154
 
155
+ /**
156
+ * The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
157
+ * artifact for a consumer that cannot run this module.
158
+ *
159
+ * The scanner below is AUTHORITATIVE and this is derived from its own constants,
160
+ * never the other way round: the scanner emits token KINDS a regex cannot, and
161
+ * it is linear by construction where the regex form has to carry an explicit
162
+ * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
163
+ * a stdlib-only Python filter on an uncontrolled host, with no install path for
164
+ * this package, can read a pattern string but cannot import a tokenizer. So the
165
+ * generator pins this into `data/invisible-charset.json` beside the introducer
166
+ * set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
167
+ * hand-written spellings of one grammar.
168
+ *
169
+ * Every construct here is common to JS and Python `re` with NO flags —
170
+ * `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
171
+ * `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
172
+ * — so ONE pattern string is what both engines read.
173
+ * `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
174
+ * corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
175
+ */
176
+ export const ESCAPE_SEQUENCE_SOURCE = (() => {
177
+ const introducer7Bit = `${unicodeEscape(ESC)}${charClass(
178
+ [...STRING_INTRO_7BIT].map((ch) => ch.charCodeAt(0)),
179
+ )}`;
180
+ const c1Introducers = [...STRING_INTRO_C1].sort((a, b) => a - b);
181
+ // Consumed with the body, vs the bytes the token ends BEFORE (zero-width) so
182
+ // the scan re-reads them — the split scanControlString makes byte for byte.
183
+ const consumed = [BEL, CAN, SUB, ST_C1].sort((a, b) => a - b);
184
+ const abortBefore = [ESC, ...c1Introducers, LF, CR].sort((a, b) => a - b);
185
+ const body = `[^${[...new Set([...consumed, ...abortBefore])]
186
+ .sort((a, b) => a - b)
187
+ .map(unicodeEscape)
188
+ .join("")}]*`;
189
+ // `ESC \` is the 7-bit ST, the one two-byte terminator.
190
+ const escapeSt = `${unicodeEscape(ESC)}${unicodeEscape(0x5c)}`;
191
+ const terminator =
192
+ `(?:${charClass(consumed)}|${escapeSt}` +
193
+ `|(?=${charClass(abortBefore)})|(?![\\s\\S]))`;
194
+ const stringArm = `(?:${introducer7Bit}|${charClass(c1Introducers)})${body}${terminator}`;
195
+ // The negative lookahead pins the intro run MAXIMAL — which is what the
196
+ // scanner's `while` loop does — and in doing so removes the only place the
197
+ // two quantifiers could repartition (`;` is in both classes), so this cannot
198
+ // backtrack super-linearly the way an unbounded `[…;…]*[…;…]*` would.
199
+ const csiArm =
200
+ `${charClass([ESC, CSI_C1])}${CSI_INTRO_CLASS}*(?!${CSI_INTRO_CLASS})` +
201
+ `${CSI_PARAM_CLASS}*${CSI_FINAL_CLASS}`;
202
+ // The string arm runs FIRST for the same reason it does in scanAnsi: `P` is
203
+ // also a CSI final byte, so a CSI-first alternation takes `ESC P` alone and
204
+ // leaves the DCS body as visible text.
205
+ return `(?:${stringArm}|${csiArm})`;
206
+ })();
207
+
123
208
  /** The seven things an introducer can turn out to be. */
124
209
  export const TOKEN_KIND = Object.freeze({
125
210
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
@@ -230,7 +315,9 @@ export function orphanKindFor(ch, next) {
230
315
  * bound one stray `ESC ]` deleted every later line to end of input, so on a
231
316
  * consumer that reads the strip as a RECORD (a model, not a display) one
232
317
  * introducer blinded the whole tail behind a clean-looking prefix. The
233
- * break survives; the payload after it on the same line is dropped.
318
+ * break survives; the payload after it on the same line is dropped. This is
319
+ * what makes the layer-wide invariant hold — no token of any kind spans a
320
+ * line break, so a strip NEVER removes a newline (test/layer1-ansi).
234
321
  * 4. end of input, for a genuinely unterminated string with no line break:
235
322
  * fail closed and drop everything from the introducer on, so no body
236
323
  * survives.
@@ -1,94 +1,120 @@
1
1
  /**
2
- * WHICH files an agent loads as model context, as data: the glob sets and the
3
- * walk-pruning predicate that together define "everything Claude Code reads as
4
- * instructions, and nothing else".
5
- *
6
- * TWO scopes live here, because Claude Code has two load moments and scanning
7
- * them at one moment is what made session start unusable:
2
+ * WHICH files an agent loads as model context, as data: one table of context
3
+ * KINDS, and the views of it each load moment needs — a launch-time glob set, a
4
+ * whole-tree glob set, and the check that can tell the table it is wrong.
8
5
  *
9
6
  * - {@link CLAUDE_LAUNCH_GLOBS} + {@link ancestorInstructionFiles} — what
10
- * loads AT LAUNCH: the working directory's own instruction files, the same
11
- * files in every directory above it, and the root `.claude` context tree.
12
- * Rooted at the scan root, so it costs one shallow glob and a walk up the
13
- * parent chain no matter how large the tree below is.
7
+ * loads AT LAUNCH, costing a shallow glob and a walk up the parent chain
8
+ * whatever the tree below holds.
14
9
  * - {@link CLAUDE_INSTRUCTION_GLOBS} — every instruction file ANYWHERE in a
15
- * tree, `**`-rooted. A library caller asking "scan this project" wants
16
- * this; a SessionStart hook must not, because Claude Code loads a
17
- * subdirectory's `CLAUDE.md` only when it reads a file in that
18
- * subdirectory, and a launch in `$HOME` charges the whole home tree
19
- * ~100 seconds of blocked startup for files that mostly never load.
20
- * Those lazily-loaded files are scanned by the InstructionsLoaded hook, at
21
- * the moment they load.
22
- *
23
- * This is the SINGLE SOURCE for both. It used to live inside
24
- * `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
25
- * knew the answer and nobody else did: `src/instructions.mjs` takes
26
- * caller-supplied globs by design (no agent's convention is baked into the
27
- * engine), so the CLI, the Python port and every downstream fork spelled their
28
- * own approximation of this list — and an approximation that drifts either
29
- * scans bulk data that can never reach the model or MISSES a context directory
30
- * entirely, which is a silent hole in the one scan standing between a poisoned
31
- * instruction file and a session that loads it.
32
- *
33
- * It is a standalone DATA module carrying no package dependency (like
34
- * ./cf-charset.mjs) for two reasons: `src/instructions.mjs` re-exports it as the
35
- * library's public door, and the hook imports it RELATIVELY — deliberately not
36
- * through the `agent-sanitizer` specifier the plugin bundle pins to a published
37
- * engine. This scope is hook POLICY, not engine behavior: it must ship and move
38
- * with the hook that walks it, or a plugin built against an older pin would
39
- * prune the wrong directories while believing it had scanned everything.
10
+ * tree. A SessionStart hook must not walk this: a launch in `$HOME` charges
11
+ * the whole home tree ~100 seconds of blocked startup — for files that
12
+ * mostly never load, and the InstructionsLoaded hook scans those as they do.
13
+ * - {@link contextScopeContradiction} what a file the host just loaded says
14
+ * about this table, so a stale row surfaces as a notice, not as a hole.
15
+ *
16
+ * A standalone DATA module with no package dependency: `src/instructions.mjs`
17
+ * re-exports it as the library's public door, and the hooks import it RELATIVELY
18
+ * rather than through the `agent-sanitizer` specifier the plugin bundle pins to a
19
+ * published engine. This scope is hook POLICY, not engine behavior — it must move
20
+ * with the hook that walks it, or a plugin built against an older pin prunes the
21
+ * wrong directories while believing it scanned everything.
40
22
  */
41
23
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
42
24
 
25
+ // One row of the kind table. Both flags default to the conservative answer —
26
+ // this kind is not on the ancestor chain, and no event announces it — so a row
27
+ // added without them claims nothing the host has not been observed doing. Both
28
+ // describe how a kind LOADS, so neither means anything on a `claude-bulk` row:
29
+ // the readers below gate on shape before they read either flag.
43
30
  /**
44
- * The `.claude/` subdirectories whose markdown Claude Code loads as model
45
- * context. This is a WHITELIST, and that is the point: `.claude/` is also where
46
- * tooling parks bulk data that is never loaded as context — `worktrees/`
47
- * (entire checked-out copies of the repo), plus caches, transcripts and
48
- * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
49
- * populated worktrees that is thousands of files READ at every session start:
50
- * one report put it at 30 seconds of blocked startup, paid for scanning files
51
- * that cannot reach the model.
52
- *
53
- * A whitelist, not a `worktrees` denylist, because the failure modes are not
54
- * symmetric: an unlisted context directory costs a scan nobody asked for anyway
55
- * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
56
- * while an unlisted BULK directory silently costs every future session its
57
- * startup. Add an entry here when Claude Code starts loading a new `.claude/`
58
- * subdirectory as context.
31
+ * @param {"dir-file" | "claude-md" | "claude-subdir" | "claude-bulk"} shape
32
+ * @param {string} name how a reader spells this kind
33
+ * @param {{ ancestorChain?: boolean, eventNamed?: boolean }} [flags]
59
34
  */
60
- export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
61
- "agents",
62
- "commands",
63
- "output-styles",
64
- "rules",
65
- "skills",
66
- ]);
35
+ function kind(shape, name, { ancestorChain = false, eventNamed = false } = {}) {
36
+ return Object.freeze({ shape, name, ancestorChain, eventNamed });
37
+ }
67
38
 
68
39
  /**
69
- * Claude Code's own per-directory memory files. Their own list because the
70
- * parent-chain load ({@link ancestorInstructionFiles}) is Claude Code's rule and
71
- * covers exactly these two.
40
+ * Every kind of file an agent loads as model context — plus, as `claude-bulk`
41
+ * rows, the `.claude/` directories that hold anything BUT context, so a consumer
42
+ * filtering this table must filter on `shape` and never take it whole.
43
+ *
44
+ * Each row carries the two facts code branches on. `shape` says where the kind
45
+ * lives; `ancestorChain` says whether Claude Code also loads it from the
46
+ * directories ABOVE a scan root; `eventNamed` says whether `InstructionsLoaded`
47
+ * names it as it loads, the claim {@link contextScopeContradiction} checks.
48
+ *
49
+ * Shapes:
50
+ * - `dir-file` — `name`, in any directory (`packages/foo/CLAUDE.md`).
51
+ * - `claude-md` — top-level markdown directly under a `.claude/` directory.
52
+ * - `claude-subdir` — `.claude/<name>/` and everything markdown below it.
53
+ * - `claude-bulk` — `.claude/<name>/`, holding data that is not context.
54
+ *
55
+ * The `claude-subdir` rows are a WHITELIST: an unlisted context directory costs
56
+ * a scan nobody paid for anyway, while an unlisted BULK directory costs every
57
+ * future session its startup. The `claude-bulk` rows name the bulk directories
58
+ * this project has seen, so a load out of one asks for no whitelist entry.
72
59
  */
73
- export const CLAUDE_MEMORY_FILES = Object.freeze([
74
- "CLAUDE.md",
75
- "CLAUDE.local.md",
60
+ export const CLAUDE_CONTEXT_KINDS = Object.freeze([
61
+ kind("dir-file", "CLAUDE.md", { ancestorChain: true, eventNamed: true }),
62
+ kind("dir-file", "CLAUDE.local.md", {
63
+ ancestorChain: true,
64
+ eventNamed: true,
65
+ }),
66
+ // AGENTS.md is the cross-agent convention Claude Code does not read itself,
67
+ // kept because this package guards agents generally. Off the ancestor chain
68
+ // for the same reason: that load is Claude Code's rule.
69
+ kind("dir-file", "AGENTS.md"),
70
+ kind("claude-md", ".claude/*.md"),
71
+ kind("claude-subdir", "agents"),
72
+ kind("claude-subdir", "commands"),
73
+ kind("claude-subdir", "output-styles"),
74
+ kind("claude-subdir", "rules", { eventNamed: true }),
75
+ kind("claude-subdir", "skills"),
76
+ // Repo checkouts and session transcripts: storage the host writes and reads
77
+ // back, so a load out of one is not evidence that the whitelist is short.
78
+ kind("claude-bulk", "worktrees"),
79
+ kind("claude-bulk", "projects"),
80
+ kind("claude-bulk", "todos"),
76
81
  ]);
77
82
 
83
+ /** The rows of one shape, in table order. @param {string} shape */
84
+ function kindsOfShape(shape) {
85
+ return CLAUDE_CONTEXT_KINDS.filter((row) => row.shape === shape);
86
+ }
87
+
88
+ // The names of `rows`, frozen: what a caller that wants one shape's spelling —
89
+ // a glob builder, the parent-chain walk — reads off the table.
90
+ /** @param {readonly {name: string}[]} rows @returns {readonly string[]} */
91
+ function namesOf(rows) {
92
+ return Object.freeze(rows.map((row) => row.name));
93
+ }
94
+
95
+ /** The `.claude/` subdirectories whose markdown loads as model context. */
96
+ export const CLAUDE_CONTEXT_SUBDIRS = namesOf(kindsOfShape("claude-subdir"));
97
+
98
+ /** The `.claude/` subdirectories known to hold storage rather than context. */
99
+ const CLAUDE_BULK_SUBDIRS = namesOf(kindsOfShape("claude-bulk"));
100
+
78
101
  /**
79
- * Every per-directory instruction file: Claude Code's memory files plus
80
- * `AGENTS.md`, the cross-agent convention Claude Code does not read itself, kept
81
- * because this package guards agents generally and the file is loaded as
82
- * instructions by the ones that do.
102
+ * Claude Code's own per-directory memory files: the kinds it loads from every
103
+ * directory above a scan root as well as from the root itself.
83
104
  */
84
- export const CLAUDE_DIR_INSTRUCTION_FILES = Object.freeze([
85
- ...CLAUDE_MEMORY_FILES,
86
- "AGENTS.md",
87
- ]);
105
+ export const CLAUDE_MEMORY_FILES = namesOf(
106
+ // Shape first: only a per-directory file can be walked up a parent chain, so
107
+ // no `.claude/` row can reach this list whatever its flags say.
108
+ CLAUDE_CONTEXT_KINDS.filter(
109
+ (row) => row.shape === "dir-file" && row.ancestorChain,
110
+ ),
111
+ );
112
+
113
+ /** Every per-directory instruction file, memory files and `AGENTS.md` alike. */
114
+ export const CLAUDE_DIR_INSTRUCTION_FILES = namesOf(kindsOfShape("dir-file"));
88
115
 
89
116
  // The glob patterns for one `.claude` tree at `prefix` (empty for the scan root,
90
- // a doubled-star segment for nested ones): its top-level markdown, plus the
91
- // whitelisted context subdirectories. Built once, from the one list above.
117
+ // a doubled-star segment for nested ones), built from the table's rows.
92
118
  /** @param {string} prefix @returns {string[]} */
93
119
  function claudeDirPatterns(prefix) {
94
120
  return [
@@ -98,12 +124,10 @@ function claudeDirPatterns(prefix) {
98
124
  }
99
125
 
100
126
  /**
101
- * Every glob whose matches Claude Code loads as model context ANYWHERE in a
102
- * tree: the per-directory instruction files (CLAUDE.md, CLAUDE.local.md,
103
- * AGENTS.md) and the whitelisted `.claude/` markdown. Claude Code loads these on
104
- * entry to their containing directory a load path that bypasses the PostToolUse
105
- * sanitizer — so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the
106
- * model uncleaned unless something scans it.
127
+ * Every glob whose matches an agent loads as model context ANYWHERE in a tree.
128
+ * Claude Code loads these on entry to their containing directory a load path
129
+ * that bypasses the PostToolUse sanitizer so a payload planted in e.g.
130
+ * `packages/foo/CLAUDE.md` reaches the model uncleaned unless something scans it.
107
131
  *
108
132
  * This is the WHOLE-TREE scope, for a caller scanning a project on demand (the
109
133
  * CLI, the Python port). It is not what a SessionStart hook walks — see
@@ -125,10 +149,8 @@ export const CLAUDE_INSTRUCTION_GLOBS = Object.freeze([
125
149
  ]);
126
150
 
127
151
  /**
128
- * Every glob whose matches Claude Code loads AT LAUNCH from the scan root
129
- * itself: the root's own instruction files and its `.claude` context tree. Same
130
- * patterns as {@link CLAUDE_INSTRUCTION_GLOBS} without the doubled-star root, built
131
- * from the same two lists so the pair cannot drift.
152
+ * Every glob whose matches load AT LAUNCH from the scan root itself: the root's
153
+ * own instruction files and its `.claude` context tree.
132
154
  *
133
155
  * Deliberately NOT recursive. A subdirectory's `CLAUDE.md` is loaded when Claude
134
156
  * Code reads a file in that subdirectory, not at launch, so globbing for it at
@@ -147,12 +169,9 @@ export const CLAUDE_LAUNCH_GLOBS = Object.freeze([
147
169
 
148
170
  /**
149
171
  * The instruction files Claude Code loads from the directories ABOVE `dir`:
150
- * walking up to the filesystem root, `CLAUDE.md` and `CLAUDE.local.md` in each
151
- * parent are loaded IN FULL at launch, so a payload planted in a parent
152
- * directory reaches the model exactly like one in the project's own file.
153
- *
154
- * `AGENTS.md` is absent by design: the parent-chain load is Claude Code's rule,
155
- * and Claude Code does not read `AGENTS.md`.
172
+ * walking up to the filesystem root, every `ancestorChain` kind in each parent
173
+ * is loaded IN FULL at launch, so a payload planted in a parent directory
174
+ * reaches the model exactly like one in the project's own file.
156
175
  *
157
176
  * Returns CANDIDATES — absolute paths, existing or not, because this module
158
177
  * touches no filesystem. Most parents of any directory hold neither file, so a
@@ -210,6 +229,21 @@ export function excludeNodeModules(entry) {
210
229
  return entry === "node_modules";
211
230
  }
212
231
 
232
+ // The path segments below one `.claude` directory in `path`, or null when it
233
+ // names none. Pruning asks whether the path is inside a bulk directory of the
234
+ // tree being walked, so it takes the OUTERMOST — a `worktrees/` checkout carries
235
+ // a whole `.claude` of its own, below which the prune must keep applying.
236
+ // Naming a kind asks what the file IS, so it takes the INNERMOST tree.
237
+ /** @param {string} path @param {"outermost" | "innermost"} which */
238
+ function claudeTail(path, which) {
239
+ const parts = path.split(/[/\\]/);
240
+ const claudeIndex =
241
+ which === "outermost"
242
+ ? parts.indexOf(".claude")
243
+ : parts.lastIndexOf(".claude");
244
+ return claudeIndex === -1 ? null : parts.slice(claudeIndex + 1);
245
+ }
246
+
213
247
  /**
214
248
  * Entries a context scan must not descend into or return: `node_modules`, and
215
249
  * every child of a `.claude` directory that is not whitelisted context.
@@ -217,10 +251,10 @@ export function excludeNodeModules(entry) {
217
251
  * The globs alone would already refuse to MATCH those files, but a glob walker
218
252
  * calls this on directories as it walks and prunes the ones it rejects — which
219
253
  * is where the cost actually is. Without the prune, a `.claude/worktrees/`
220
- * holding a few repo checkouts is walked in full on every session start (and,
221
- * because a doubled-star segment does cross into a dot directory when the
222
- * pattern names one, a `.claude` NESTED inside a worktree was matched and
223
- * scanned as if it were this session's context).
254
+ * holding a few repo checkouts is walked in full on every session start, and a
255
+ * `.claude` NESTED inside a worktree is scanned as if it were this session's
256
+ * context: a doubled-star segment does cross into a dot directory when the
257
+ * pattern names one.
224
258
  *
225
259
  * A walker calls this with both bare names and root-relative paths, so it must
226
260
  * answer for either; a bare name carries no `.claude` context and is judged only
@@ -230,12 +264,95 @@ export function excludeNodeModules(entry) {
230
264
  */
231
265
  export function excludeFromContextScan(entry) {
232
266
  if (excludeNodeModules(entry)) return true;
233
- const parts = entry.split(/[/\\]/);
234
- const claudeIndex = parts.indexOf(".claude");
235
- const tail = parts.slice(claudeIndex + 1);
236
- if (claudeIndex === -1 || tail.length === 0) return false;
267
+ const tail = claudeTail(entry, "outermost");
268
+ if (tail === null || tail.length === 0) return false;
237
269
  // `.claude/<file>.md` is context (a top-level note); anything else directly
238
270
  // under `.claude` must be a whitelisted subdirectory to be walked at all.
239
271
  if (tail.length === 1 && tail[0].endsWith(".md")) return false;
240
272
  return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
241
273
  }
274
+
275
+ /**
276
+ * The kind `path` is an instance of, or null when this table claims none — an
277
+ * `@import` of an arbitrary markdown file, a source file, an unlisted `.claude`
278
+ * directory. Null is the honest answer for anything the table does not name, and
279
+ * callers must treat it as "no claim", never as "not context".
280
+ * @param {string} path absolute or relative; only its segments are read
281
+ * @returns {(typeof CLAUDE_CONTEXT_KINDS)[number] | null}
282
+ */
283
+ function classifyContextPath(path) {
284
+ const segments = path.split(/[/\\]/);
285
+ const name = segments[segments.length - 1];
286
+ // A CLAUDE.md is that kind wherever it sits, `.claude` tree or not — which is
287
+ // what keeps the ordinary nested-memory load from reading as evidence about
288
+ // the directory it happens to sit under.
289
+ const dirFile = kindsOfShape("dir-file").find((row) => row.name === name);
290
+ const tail = claudeTail(path, "innermost");
291
+ if (dirFile || tail === null) return dirFile ?? null;
292
+ if (tail.length === 1 && name.endsWith(".md"))
293
+ return kindsOfShape("claude-md")[0];
294
+ // Both directory shapes, so a bulk directory classifies as itself rather than
295
+ // falling through to the unlisted-directory report below.
296
+ const dirShapes = ["claude-subdir", "claude-bulk"];
297
+ return (
298
+ CLAUDE_CONTEXT_KINDS.find(
299
+ (row) => dirShapes.includes(row.shape) && row.name === tail[0],
300
+ ) ?? null
301
+ );
302
+ }
303
+
304
+ // The `load_reason` values that mean Claude Code reached the file on its own —
305
+ // its launch scan, and its walk into a directory. Every other reason names a
306
+ // file something else chose, which is not evidence about the scan's scope.
307
+ const HOST_CHOSEN_LOAD_REASONS = ["session_start", "nested_traversal"];
308
+
309
+ /**
310
+ * What a file the host just loaded as model context says about this table, or
311
+ * null when it says nothing new. The InstructionsLoaded event is the only
312
+ * observation that can prove the table wrong, and this is what it proves:
313
+ *
314
+ * - a `.claude/` subdirectory outside {@link CLAUDE_CONTEXT_SUBDIRS} loading
315
+ * as context means the launch scan skips that whole directory — the file
316
+ * here was scanned, every other file in it was not;
317
+ * - a kind the table marks `eventNamed: false` being named means the event's
318
+ * coverage is wider than the docs claim, and the lazy scan reaches files
319
+ * nothing was crediting it with.
320
+ *
321
+ * Both observations are about what the host reaches ON ITS OWN, so both require
322
+ * a host-chosen `loadReason`: an `@import` names a file the user's own markdown
323
+ * pointed at, and acting on it would either whitelist an import target or credit
324
+ * the event with a kind it reaches only when imported. An unrecognized reason is
325
+ * treated the same way, so this loses a notice rather than inventing one.
326
+ *
327
+ * A path the table does not name at all says nothing about the table either, so
328
+ * it returns null rather than guessing. A `claude-bulk` row is silent for the
329
+ * reason in reverse: the table already knows that directory is storage.
330
+ * @param {string} path the path the host loaded
331
+ * @param {string} loadReason the event's `load_reason`, or "unknown" when the
332
+ * host sent none; required rather than defaulted, since every observation here
333
+ * holds only for a load the host chose itself
334
+ * @returns {string | null} what is stale, phrased for whoever fixes the table
335
+ */
336
+ export function contextScopeContradiction(path, loadReason) {
337
+ if (!HOST_CHOSEN_LOAD_REASONS.includes(loadReason)) return null;
338
+ const row = classifyContextPath(path);
339
+ if (row?.eventNamed || row?.shape === "claude-bulk") return null;
340
+ if (row)
341
+ return (
342
+ `InstructionsLoaded named ${row.name}, which CLAUDE_CONTEXT_KINDS records as a kind the ` +
343
+ "event never names: the lazy scan reaches further than this table, and the docs built " +
344
+ "on it, claim"
345
+ );
346
+ const tail = claudeTail(path, "innermost");
347
+ if (tail === null || tail.length < 2) return null;
348
+ // A `.claude` tree nested inside storage describes that checkout's own layout,
349
+ // not this project's: whitelisting a directory that exists only inside a
350
+ // pruned worktree adds nothing the launch scan would ever walk.
351
+ const outer = /** @type {string[]} */ (claudeTail(path, "outermost"));
352
+ if (CLAUDE_BULK_SUBDIRS.includes(outer[0])) return null;
353
+ return (
354
+ `.claude/${tail[0]}/ loaded as model context, and CLAUDE_CONTEXT_SUBDIRS does not list it: ` +
355
+ "the SessionStart scan prunes that directory, so every OTHER file in it goes unscanned. " +
356
+ "Add it there if it is context, not bulk data"
357
+ );
358
+ }
@@ -51,11 +51,13 @@ import { excludeNodeModules } from "./claude-context.mjs";
51
51
  // reads that scope from instead of re-spelling it.
52
52
  export {
53
53
  ancestorInstructionFiles,
54
+ CLAUDE_CONTEXT_KINDS,
54
55
  CLAUDE_CONTEXT_SUBDIRS,
55
56
  CLAUDE_DIR_INSTRUCTION_FILES,
56
57
  CLAUDE_INSTRUCTION_GLOBS,
57
58
  CLAUDE_LAUNCH_GLOBS,
58
59
  CLAUDE_MEMORY_FILES,
60
+ contextScopeContradiction,
59
61
  excludeFromContextScan,
60
62
  } from "./claude-context.mjs";
61
63
 
package/types/ansi.d.mts CHANGED
@@ -65,6 +65,28 @@ export const CONTROL_INTRODUCER_SOURCE: string;
65
65
  * and the regex can no longer describe different languages.
66
66
  */
67
67
  export const SGR_RE: RegExp;
68
+ /**
69
+ * The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
70
+ * artifact for a consumer that cannot run this module.
71
+ *
72
+ * The scanner below is AUTHORITATIVE and this is derived from its own constants,
73
+ * never the other way round: the scanner emits token KINDS a regex cannot, and
74
+ * it is linear by construction where the regex form has to carry an explicit
75
+ * guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
76
+ * a stdlib-only Python filter on an uncontrolled host, with no install path for
77
+ * this package, can read a pattern string but cannot import a tokenizer. So the
78
+ * generator pins this into `data/invisible-charset.json` beside the introducer
79
+ * set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
80
+ * hand-written spellings of one grammar.
81
+ *
82
+ * Every construct here is common to JS and Python `re` with NO flags —
83
+ * `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
84
+ * `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
85
+ * — so ONE pattern string is what both engines read.
86
+ * `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
87
+ * corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
88
+ */
89
+ export const ESCAPE_SEQUENCE_SOURCE: string;
68
90
  /** The seven things an introducer can turn out to be. */
69
91
  export const TOKEN_KIND: Readonly<{
70
92
  /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * The instruction files Claude Code loads from the directories ABOVE `dir`:
3
- * walking up to the filesystem root, `CLAUDE.md` and `CLAUDE.local.md` in each
4
- * parent are loaded IN FULL at launch, so a payload planted in a parent
5
- * directory reaches the model exactly like one in the project's own file.
6
- *
7
- * `AGENTS.md` is absent by design: the parent-chain load is Claude Code's rule,
8
- * and Claude Code does not read `AGENTS.md`.
3
+ * walking up to the filesystem root, every `ancestorChain` kind in each parent
4
+ * is loaded IN FULL at launch, so a payload planted in a parent directory
5
+ * reaches the model exactly like one in the project's own file.
9
6
  *
10
7
  * Returns CANDIDATES — absolute paths, existing or not, because this module
11
8
  * touches no filesystem. Most parents of any directory hold neither file, so a
@@ -47,10 +44,10 @@ export function excludeNodeModules(entry: string): boolean;
47
44
  * The globs alone would already refuse to MATCH those files, but a glob walker
48
45
  * calls this on directories as it walks and prunes the ones it rejects — which
49
46
  * is where the cost actually is. Without the prune, a `.claude/worktrees/`
50
- * holding a few repo checkouts is walked in full on every session start (and,
51
- * because a doubled-star segment does cross into a dot directory when the
52
- * pattern names one, a `.claude` NESTED inside a worktree was matched and
53
- * scanned as if it were this session's context).
47
+ * holding a few repo checkouts is walked in full on every session start, and a
48
+ * `.claude` NESTED inside a worktree is scanned as if it were this session's
49
+ * context: a doubled-star segment does cross into a dot directory when the
50
+ * pattern names one.
54
51
  *
55
52
  * A walker calls this with both bare names and root-relative paths, so it must
56
53
  * answer for either; a bare name carries no `.claude` context and is judged only
@@ -60,43 +57,74 @@ export function excludeNodeModules(entry: string): boolean;
60
57
  */
61
58
  export function excludeFromContextScan(entry: string): boolean;
62
59
  /**
63
- * The `.claude/` subdirectories whose markdown Claude Code loads as model
64
- * context. This is a WHITELIST, and that is the point: `.claude/` is also where
65
- * tooling parks bulk data that is never loaded as context `worktrees/`
66
- * (entire checked-out copies of the repo), plus caches, transcripts and
67
- * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
68
- * populated worktrees that is thousands of files READ at every session start:
69
- * one report put it at 30 seconds of blocked startup, paid for scanning files
70
- * that cannot reach the model.
60
+ * What a file the host just loaded as model context says about this table, or
61
+ * null when it says nothing new. The InstructionsLoaded event is the only
62
+ * observation that can prove the table wrong, and this is what it proves:
63
+ *
64
+ * - a `.claude/` subdirectory outside {@link CLAUDE_CONTEXT_SUBDIRS} loading
65
+ * as context means the launch scan skips that whole directory the file
66
+ * here was scanned, every other file in it was not;
67
+ * - a kind the table marks `eventNamed: false` being named means the event's
68
+ * coverage is wider than the docs claim, and the lazy scan reaches files
69
+ * nothing was crediting it with.
70
+ *
71
+ * Both observations are about what the host reaches ON ITS OWN, so both require
72
+ * a host-chosen `loadReason`: an `@import` names a file the user's own markdown
73
+ * pointed at, and acting on it would either whitelist an import target or credit
74
+ * the event with a kind it reaches only when imported. An unrecognized reason is
75
+ * treated the same way, so this loses a notice rather than inventing one.
71
76
  *
72
- * A whitelist, not a `worktrees` denylist, because the failure modes are not
73
- * symmetric: an unlisted context directory costs a scan nobody asked for anyway
74
- * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
75
- * while an unlisted BULK directory silently costs every future session its
76
- * startup. Add an entry here when Claude Code starts loading a new `.claude/`
77
- * subdirectory as context.
77
+ * A path the table does not name at all says nothing about the table either, so
78
+ * it returns null rather than guessing. A `claude-bulk` row is silent for the
79
+ * reason in reverse: the table already knows that directory is storage.
80
+ * @param {string} path the path the host loaded
81
+ * @param {string} loadReason the event's `load_reason`, or "unknown" when the
82
+ * host sent none; required rather than defaulted, since every observation here
83
+ * holds only for a load the host chose itself
84
+ * @returns {string | null} what is stale, phrased for whoever fixes the table
78
85
  */
79
- export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
86
+ export function contextScopeContradiction(path: string, loadReason: string): string | null;
80
87
  /**
81
- * Claude Code's own per-directory memory files. Their own list because the
82
- * parent-chain load ({@link ancestorInstructionFiles}) is Claude Code's rule and
83
- * covers exactly these two.
88
+ * Every kind of file an agent loads as model context — plus, as `claude-bulk`
89
+ * rows, the `.claude/` directories that hold anything BUT context, so a consumer
90
+ * filtering this table must filter on `shape` and never take it whole.
91
+ *
92
+ * Each row carries the two facts code branches on. `shape` says where the kind
93
+ * lives; `ancestorChain` says whether Claude Code also loads it from the
94
+ * directories ABOVE a scan root; `eventNamed` says whether `InstructionsLoaded`
95
+ * names it as it loads, the claim {@link contextScopeContradiction} checks.
96
+ *
97
+ * Shapes:
98
+ * - `dir-file` — `name`, in any directory (`packages/foo/CLAUDE.md`).
99
+ * - `claude-md` — top-level markdown directly under a `.claude/` directory.
100
+ * - `claude-subdir` — `.claude/<name>/` and everything markdown below it.
101
+ * - `claude-bulk` — `.claude/<name>/`, holding data that is not context.
102
+ *
103
+ * The `claude-subdir` rows are a WHITELIST: an unlisted context directory costs
104
+ * a scan nobody paid for anyway, while an unlisted BULK directory costs every
105
+ * future session its startup. The `claude-bulk` rows name the bulk directories
106
+ * this project has seen, so a load out of one asks for no whitelist entry.
84
107
  */
85
- export const CLAUDE_MEMORY_FILES: readonly string[];
108
+ export const CLAUDE_CONTEXT_KINDS: readonly Readonly<{
109
+ shape: "dir-file" | "claude-md" | "claude-subdir" | "claude-bulk";
110
+ name: string;
111
+ ancestorChain: boolean;
112
+ eventNamed: boolean;
113
+ }>[];
114
+ /** The `.claude/` subdirectories whose markdown loads as model context. */
115
+ export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
86
116
  /**
87
- * Every per-directory instruction file: Claude Code's memory files plus
88
- * `AGENTS.md`, the cross-agent convention Claude Code does not read itself, kept
89
- * because this package guards agents generally and the file is loaded as
90
- * instructions by the ones that do.
117
+ * Claude Code's own per-directory memory files: the kinds it loads from every
118
+ * directory above a scan root as well as from the root itself.
91
119
  */
120
+ export const CLAUDE_MEMORY_FILES: readonly string[];
121
+ /** Every per-directory instruction file, memory files and `AGENTS.md` alike. */
92
122
  export const CLAUDE_DIR_INSTRUCTION_FILES: readonly string[];
93
123
  /**
94
- * Every glob whose matches Claude Code loads as model context ANYWHERE in a
95
- * tree: the per-directory instruction files (CLAUDE.md, CLAUDE.local.md,
96
- * AGENTS.md) and the whitelisted `.claude/` markdown. Claude Code loads these on
97
- * entry to their containing directory a load path that bypasses the PostToolUse
98
- * sanitizer — so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the
99
- * model uncleaned unless something scans it.
124
+ * Every glob whose matches an agent loads as model context ANYWHERE in a tree.
125
+ * Claude Code loads these on entry to their containing directory a load path
126
+ * that bypasses the PostToolUse sanitizer so a payload planted in e.g.
127
+ * `packages/foo/CLAUDE.md` reaches the model uncleaned unless something scans it.
100
128
  *
101
129
  * This is the WHOLE-TREE scope, for a caller scanning a project on demand (the
102
130
  * CLI, the Python port). It is not what a SessionStart hook walks — see
@@ -114,10 +142,8 @@ export const CLAUDE_DIR_INSTRUCTION_FILES: readonly string[];
114
142
  */
115
143
  export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
116
144
  /**
117
- * Every glob whose matches Claude Code loads AT LAUNCH from the scan root
118
- * itself: the root's own instruction files and its `.claude` context tree. Same
119
- * patterns as {@link CLAUDE_INSTRUCTION_GLOBS} without the doubled-star root, built
120
- * from the same two lists so the pair cannot drift.
145
+ * Every glob whose matches load AT LAUNCH from the scan root itself: the root's
146
+ * own instruction files and its `.claude` context tree.
121
147
  *
122
148
  * Deliberately NOT recursive. A subdirectory's `CLAUDE.md` is loaded when Claude
123
149
  * Code reads a file in that subdirectory, not at launch, so globbing for it at
@@ -38,6 +38,22 @@ export function scanLoadedFile(filePath: string, { projectDir, clean, read }?: {
38
38
  cleaned: boolean;
39
39
  reason: string | null;
40
40
  } | null;
41
+ /**
42
+ * The operator-facing line for a file whose path contradicts the scope table, or
43
+ * null when it does not. This hook is where that check belongs and the only
44
+ * place it can run: the host naming a file as it loads is the one observation
45
+ * that can prove the SessionStart scan's scope wrong, and a scope that is wrong
46
+ * about a `.claude/` subdirectory is a launch scan with a hole in it.
47
+ *
48
+ * Separate from the finding channels below: this is a maintenance signal about
49
+ * THIS package, not a verdict about the file, so it never reaches the model and
50
+ * never arms the tool-call gate.
51
+ * @param {string} filePath
52
+ * @param {string} loadReason why the host loaded it, which decides whether the
53
+ * load is evidence about the scan's scope at all
54
+ * @returns {string | null}
55
+ */
56
+ export function scopeNotice(filePath: string, loadReason: string): string | null;
41
57
  /**
42
58
  * The operator- and model-facing text for a scanned file. Both channels carry
43
59
  * it: the bytes are already in context, so the model is told to distrust what it
@@ -148,4 +148,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
148
148
  * @returns {boolean}
149
149
  */
150
150
  export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
151
- export { ancestorInstructionFiles, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, excludeFromContextScan } from "./claude-context.mjs";
151
+ export { ancestorInstructionFiles, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan } from "./claude-context.mjs";
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * The instruction files Claude Code loads from the directories ABOVE `dir`:
3
- * walking up to the filesystem root, `CLAUDE.md` and `CLAUDE.local.md` in each
4
- * parent are loaded IN FULL at launch, so a payload planted in a parent
5
- * directory reaches the model exactly like one in the project's own file.
6
- *
7
- * `AGENTS.md` is absent by design: the parent-chain load is Claude Code's rule,
8
- * and Claude Code does not read `AGENTS.md`.
3
+ * walking up to the filesystem root, every `ancestorChain` kind in each parent
4
+ * is loaded IN FULL at launch, so a payload planted in a parent directory
5
+ * reaches the model exactly like one in the project's own file.
9
6
  *
10
7
  * Returns CANDIDATES — absolute paths, existing or not, because this module
11
8
  * touches no filesystem. Most parents of any directory hold neither file, so a
@@ -47,10 +44,10 @@ export function excludeNodeModules(entry: string): boolean;
47
44
  * The globs alone would already refuse to MATCH those files, but a glob walker
48
45
  * calls this on directories as it walks and prunes the ones it rejects — which
49
46
  * is where the cost actually is. Without the prune, a `.claude/worktrees/`
50
- * holding a few repo checkouts is walked in full on every session start (and,
51
- * because a doubled-star segment does cross into a dot directory when the
52
- * pattern names one, a `.claude` NESTED inside a worktree was matched and
53
- * scanned as if it were this session's context).
47
+ * holding a few repo checkouts is walked in full on every session start, and a
48
+ * `.claude` NESTED inside a worktree is scanned as if it were this session's
49
+ * context: a doubled-star segment does cross into a dot directory when the
50
+ * pattern names one.
54
51
  *
55
52
  * A walker calls this with both bare names and root-relative paths, so it must
56
53
  * answer for either; a bare name carries no `.claude` context and is judged only
@@ -60,43 +57,74 @@ export function excludeNodeModules(entry: string): boolean;
60
57
  */
61
58
  export function excludeFromContextScan(entry: string): boolean;
62
59
  /**
63
- * The `.claude/` subdirectories whose markdown Claude Code loads as model
64
- * context. This is a WHITELIST, and that is the point: `.claude/` is also where
65
- * tooling parks bulk data that is never loaded as context `worktrees/`
66
- * (entire checked-out copies of the repo), plus caches, transcripts and
67
- * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
68
- * populated worktrees that is thousands of files READ at every session start:
69
- * one report put it at 30 seconds of blocked startup, paid for scanning files
70
- * that cannot reach the model.
60
+ * What a file the host just loaded as model context says about this table, or
61
+ * null when it says nothing new. The InstructionsLoaded event is the only
62
+ * observation that can prove the table wrong, and this is what it proves:
63
+ *
64
+ * - a `.claude/` subdirectory outside {@link CLAUDE_CONTEXT_SUBDIRS} loading
65
+ * as context means the launch scan skips that whole directory the file
66
+ * here was scanned, every other file in it was not;
67
+ * - a kind the table marks `eventNamed: false` being named means the event's
68
+ * coverage is wider than the docs claim, and the lazy scan reaches files
69
+ * nothing was crediting it with.
70
+ *
71
+ * Both observations are about what the host reaches ON ITS OWN, so both require
72
+ * a host-chosen `loadReason`: an `@import` names a file the user's own markdown
73
+ * pointed at, and acting on it would either whitelist an import target or credit
74
+ * the event with a kind it reaches only when imported. An unrecognized reason is
75
+ * treated the same way, so this loses a notice rather than inventing one.
71
76
  *
72
- * A whitelist, not a `worktrees` denylist, because the failure modes are not
73
- * symmetric: an unlisted context directory costs a scan nobody asked for anyway
74
- * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
75
- * while an unlisted BULK directory silently costs every future session its
76
- * startup. Add an entry here when Claude Code starts loading a new `.claude/`
77
- * subdirectory as context.
77
+ * A path the table does not name at all says nothing about the table either, so
78
+ * it returns null rather than guessing. A `claude-bulk` row is silent for the
79
+ * reason in reverse: the table already knows that directory is storage.
80
+ * @param {string} path the path the host loaded
81
+ * @param {string} loadReason the event's `load_reason`, or "unknown" when the
82
+ * host sent none; required rather than defaulted, since every observation here
83
+ * holds only for a load the host chose itself
84
+ * @returns {string | null} what is stale, phrased for whoever fixes the table
78
85
  */
79
- export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
86
+ export function contextScopeContradiction(path: string, loadReason: string): string | null;
80
87
  /**
81
- * Claude Code's own per-directory memory files. Their own list because the
82
- * parent-chain load ({@link ancestorInstructionFiles}) is Claude Code's rule and
83
- * covers exactly these two.
88
+ * Every kind of file an agent loads as model context — plus, as `claude-bulk`
89
+ * rows, the `.claude/` directories that hold anything BUT context, so a consumer
90
+ * filtering this table must filter on `shape` and never take it whole.
91
+ *
92
+ * Each row carries the two facts code branches on. `shape` says where the kind
93
+ * lives; `ancestorChain` says whether Claude Code also loads it from the
94
+ * directories ABOVE a scan root; `eventNamed` says whether `InstructionsLoaded`
95
+ * names it as it loads, the claim {@link contextScopeContradiction} checks.
96
+ *
97
+ * Shapes:
98
+ * - `dir-file` — `name`, in any directory (`packages/foo/CLAUDE.md`).
99
+ * - `claude-md` — top-level markdown directly under a `.claude/` directory.
100
+ * - `claude-subdir` — `.claude/<name>/` and everything markdown below it.
101
+ * - `claude-bulk` — `.claude/<name>/`, holding data that is not context.
102
+ *
103
+ * The `claude-subdir` rows are a WHITELIST: an unlisted context directory costs
104
+ * a scan nobody paid for anyway, while an unlisted BULK directory costs every
105
+ * future session its startup. The `claude-bulk` rows name the bulk directories
106
+ * this project has seen, so a load out of one asks for no whitelist entry.
84
107
  */
85
- export const CLAUDE_MEMORY_FILES: readonly string[];
108
+ export const CLAUDE_CONTEXT_KINDS: readonly Readonly<{
109
+ shape: "dir-file" | "claude-md" | "claude-subdir" | "claude-bulk";
110
+ name: string;
111
+ ancestorChain: boolean;
112
+ eventNamed: boolean;
113
+ }>[];
114
+ /** The `.claude/` subdirectories whose markdown loads as model context. */
115
+ export const CLAUDE_CONTEXT_SUBDIRS: readonly string[];
86
116
  /**
87
- * Every per-directory instruction file: Claude Code's memory files plus
88
- * `AGENTS.md`, the cross-agent convention Claude Code does not read itself, kept
89
- * because this package guards agents generally and the file is loaded as
90
- * instructions by the ones that do.
117
+ * Claude Code's own per-directory memory files: the kinds it loads from every
118
+ * directory above a scan root as well as from the root itself.
91
119
  */
120
+ export const CLAUDE_MEMORY_FILES: readonly string[];
121
+ /** Every per-directory instruction file, memory files and `AGENTS.md` alike. */
92
122
  export const CLAUDE_DIR_INSTRUCTION_FILES: readonly string[];
93
123
  /**
94
- * Every glob whose matches Claude Code loads as model context ANYWHERE in a
95
- * tree: the per-directory instruction files (CLAUDE.md, CLAUDE.local.md,
96
- * AGENTS.md) and the whitelisted `.claude/` markdown. Claude Code loads these on
97
- * entry to their containing directory a load path that bypasses the PostToolUse
98
- * sanitizer — so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the
99
- * model uncleaned unless something scans it.
124
+ * Every glob whose matches an agent loads as model context ANYWHERE in a tree.
125
+ * Claude Code loads these on entry to their containing directory a load path
126
+ * that bypasses the PostToolUse sanitizer so a payload planted in e.g.
127
+ * `packages/foo/CLAUDE.md` reaches the model uncleaned unless something scans it.
100
128
  *
101
129
  * This is the WHOLE-TREE scope, for a caller scanning a project on demand (the
102
130
  * CLI, the Python port). It is not what a SessionStart hook walks — see
@@ -114,10 +142,8 @@ export const CLAUDE_DIR_INSTRUCTION_FILES: readonly string[];
114
142
  */
115
143
  export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
116
144
  /**
117
- * Every glob whose matches Claude Code loads AT LAUNCH from the scan root
118
- * itself: the root's own instruction files and its `.claude` context tree. Same
119
- * patterns as {@link CLAUDE_INSTRUCTION_GLOBS} without the doubled-star root, built
120
- * from the same two lists so the pair cannot drift.
145
+ * Every glob whose matches load AT LAUNCH from the scan root itself: the root's
146
+ * own instruction files and its `.claude` context tree.
121
147
  *
122
148
  * Deliberately NOT recursive. A subdirectory's `CLAUDE.md` is loaded when Claude
123
149
  * Code reads a file in that subdirectory, not at launch, so globbing for it at