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 +40 -12
- package/claude-hooks/scan-loaded-instructions.mjs +38 -14
- package/package.json +1 -1
- package/src/claude-context.mjs +214 -97
- package/src/instructions.mjs +2 -0
- package/types/claude-context.d.mts +69 -43
- package/types/claude-hooks/scan-loaded-instructions.d.mts +16 -0
- package/types/instructions.d.mts +1 -1
- package/types/src/claude-context.d.mts +69 -43
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 set — the 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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
123
|
-
//
|
|
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.
|
|
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": {
|
package/src/claude-context.mjs
CHANGED
|
@@ -1,94 +1,120 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHICH files an agent loads as model context, as data:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
129
|
-
*
|
|
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, `
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
|
234
|
-
|
|
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
|
+
}
|
package/src/instructions.mjs
CHANGED
|
@@ -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, `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
86
|
+
export function contextScopeContradiction(path: string, loadReason: string): string | null;
|
|
80
87
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
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
|
-
*
|
|
88
|
-
*
|
|
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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
118
|
-
*
|
|
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
|
package/types/instructions.d.mts
CHANGED
|
@@ -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, `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
86
|
+
export function contextScopeContradiction(path: string, loadReason: string): string | null;
|
|
80
87
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
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
|
-
*
|
|
88
|
-
*
|
|
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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
118
|
-
*
|
|
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
|