agent-sanitizer 2.37.1 → 2.37.3

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.1",
3
+ "version": "2.37.3",
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,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
 
@@ -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