agent-sanitizer 2.37.0 → 2.37.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THREAT-MODEL.md +40 -12
- package/claude-hooks/scan-loaded-instructions.mjs +38 -14
- package/package.json +1 -1
- package/python/agent_sanitizer/data/invisible-charset.json +3 -2
- package/src/ansi.mjs +97 -10
- package/src/claude-context.mjs +214 -97
- package/src/instructions.mjs +2 -0
- package/types/ansi.d.mts +22 -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.2",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
|
|
2
|
+
"_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
|
|
3
3
|
"unicode_version": "17.0",
|
|
4
4
|
"extra_codepoints": [
|
|
5
5
|
847,
|
|
@@ -477,5 +477,6 @@
|
|
|
477
477
|
157,
|
|
478
478
|
158,
|
|
479
479
|
159
|
|
480
|
-
]
|
|
480
|
+
],
|
|
481
|
+
"escape_sequence_pattern": "(?:(?:\\u001b[\\u005d\\u0050\\u0058\\u005e\\u005f]|[\\u0090\\u0098\\u009d\\u009e\\u009f])[^\\u0007\\u000a\\u000d\\u0018\\u001a\\u001b\\u0090\\u0098\\u009c\\u009d\\u009e\\u009f]*(?:[\\u0007\\u0018\\u001a\\u009c]|\\u001b\\u005c|(?=[\\u000a\\u000d\\u001b\\u0090\\u0098\\u009d\\u009e\\u009f])|(?![\\s\\S]))|[\\u001b\\u009b][\\[()#;?]*(?![\\[()#;?])[0-9;:]*[A-PR-TZcf-nqrty~])"
|
|
481
482
|
}
|
package/src/ansi.mjs
CHANGED
|
@@ -43,9 +43,25 @@ export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
|
|
|
43
43
|
// grep-based drift check as well. Derived from the code-point list above so the
|
|
44
44
|
// regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
|
|
45
45
|
// control byte out of the source (no `no-control-regex` disable needed).
|
|
46
|
-
export const CONTROL_INTRODUCER_SOURCE =
|
|
47
|
-
|
|
48
|
-
)
|
|
46
|
+
export const CONTROL_INTRODUCER_SOURCE = charClass(
|
|
47
|
+
CONTROL_INTRODUCER_CODEPOINTS,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** A code point as a `\uXXXX` escape — the one spelling of a control byte that
|
|
51
|
+
* both this module's regexes and the generated Python pattern use, so no raw
|
|
52
|
+
* control byte ever lands in either source.
|
|
53
|
+
* @param {number} cp
|
|
54
|
+
* @returns {string} */
|
|
55
|
+
function unicodeEscape(cp) {
|
|
56
|
+
return `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A character class matching exactly the given code points.
|
|
60
|
+
* @param {readonly number[]} cps
|
|
61
|
+
* @returns {string} */
|
|
62
|
+
function charClass(cps) {
|
|
63
|
+
return `[${cps.map(unicodeEscape).join("")}]`;
|
|
64
|
+
}
|
|
49
65
|
|
|
50
66
|
// SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
|
|
51
67
|
// params are [0-9;:]* and the final byte is `m`, so a match can only restyle
|
|
@@ -73,10 +89,15 @@ const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
|
|
|
73
89
|
// Private parameter-prefix and intermediate bytes that may follow an
|
|
74
90
|
// introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
|
|
75
91
|
// the 7-bit `ESC [` CSI introducer's bracket itself.
|
|
76
|
-
|
|
92
|
+
// The `[` is escaped though neither engine requires it: Python's `re` warns
|
|
93
|
+
// `FutureWarning: Possible nested set` on a bare one, and this class ships as
|
|
94
|
+
// the generated pattern a Python consumer compiles.
|
|
95
|
+
const CSI_INTRO_CLASS = "[\\[()#;?]";
|
|
96
|
+
const CSI_INTRO_RE = new RegExp(CSI_INTRO_CLASS);
|
|
77
97
|
|
|
78
98
|
// ECMA-48 parameter bytes.
|
|
79
|
-
const
|
|
99
|
+
const CSI_PARAM_CLASS = "[0-9;:]";
|
|
100
|
+
const CSI_PARAM_RE = new RegExp(CSI_PARAM_CLASS);
|
|
80
101
|
|
|
81
102
|
// ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
|
|
82
103
|
// PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
|
|
@@ -86,15 +107,26 @@ const CSI_PARAM_RE = /[0-9;:]/;
|
|
|
86
107
|
// PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
|
|
87
108
|
// private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
|
|
88
109
|
// final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
|
|
89
|
-
const
|
|
110
|
+
const CSI_FINAL_CLASS = "[A-PR-TZcf-nqrty~]";
|
|
111
|
+
const CSI_FINAL_RE = new RegExp(CSI_FINAL_CLASS);
|
|
90
112
|
|
|
91
113
|
const ESC = 0x1b;
|
|
92
114
|
const CSI_C1 = 0x9b;
|
|
93
115
|
const ST_C1 = 0x9c;
|
|
94
116
|
const BEL = 0x07;
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
117
|
+
// THE ABORT SET — the four controls that end a control string short of its
|
|
118
|
+
// terminator, and the whole of it. Every other C0 control and DEL is
|
|
119
|
+
// deliberately consumed as body, because that is what a terminal does with
|
|
120
|
+
// them: DEC's parser (vt100.net/emu/dec_ansi_parser) IGNORES C0 other than
|
|
121
|
+
// CAN/SUB/ESC in `osc_string` and `sos_pm_apc_string` and `put`s them in
|
|
122
|
+
// `dcs_passthrough`, so aborting on `VT`/`FF`/`NUL`/`DEL` would end the token
|
|
123
|
+
// early and splice the rest of a payload the terminal swallows back into the
|
|
124
|
+
// model's view — the under-strip this layer exists to close.
|
|
125
|
+
// CAN/SUB — ECMA-48 and that same parser cancel the string here.
|
|
126
|
+
// LF/CR — NOT terminal behavior, a fail-closed blast-radius limit: they
|
|
127
|
+
// are the only two controls that cross a line, and a body running
|
|
128
|
+
// past one blinds a reader who consumes the strip as a RECORD
|
|
129
|
+
// rather than rendering it (see scanControlString).
|
|
98
130
|
const CAN = 0x18;
|
|
99
131
|
const SUB = 0x1a;
|
|
100
132
|
const LF = 0x0a;
|
|
@@ -120,6 +152,59 @@ const OSC_C1 = 0x9d;
|
|
|
120
152
|
// set: opening a string is exactly what ends the one already open.
|
|
121
153
|
const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
|
|
122
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
157
|
+
* artifact for a consumer that cannot run this module.
|
|
158
|
+
*
|
|
159
|
+
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
160
|
+
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
161
|
+
* it is linear by construction where the regex form has to carry an explicit
|
|
162
|
+
* guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
|
|
163
|
+
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
164
|
+
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
165
|
+
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
166
|
+
* set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
|
|
167
|
+
* hand-written spellings of one grammar.
|
|
168
|
+
*
|
|
169
|
+
* Every construct here is common to JS and Python `re` with NO flags —
|
|
170
|
+
* `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
|
|
171
|
+
* `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
|
|
172
|
+
* — so ONE pattern string is what both engines read.
|
|
173
|
+
* `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
|
|
174
|
+
* corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
|
|
175
|
+
*/
|
|
176
|
+
export const ESCAPE_SEQUENCE_SOURCE = (() => {
|
|
177
|
+
const introducer7Bit = `${unicodeEscape(ESC)}${charClass(
|
|
178
|
+
[...STRING_INTRO_7BIT].map((ch) => ch.charCodeAt(0)),
|
|
179
|
+
)}`;
|
|
180
|
+
const c1Introducers = [...STRING_INTRO_C1].sort((a, b) => a - b);
|
|
181
|
+
// Consumed with the body, vs the bytes the token ends BEFORE (zero-width) so
|
|
182
|
+
// the scan re-reads them — the split scanControlString makes byte for byte.
|
|
183
|
+
const consumed = [BEL, CAN, SUB, ST_C1].sort((a, b) => a - b);
|
|
184
|
+
const abortBefore = [ESC, ...c1Introducers, LF, CR].sort((a, b) => a - b);
|
|
185
|
+
const body = `[^${[...new Set([...consumed, ...abortBefore])]
|
|
186
|
+
.sort((a, b) => a - b)
|
|
187
|
+
.map(unicodeEscape)
|
|
188
|
+
.join("")}]*`;
|
|
189
|
+
// `ESC \` is the 7-bit ST, the one two-byte terminator.
|
|
190
|
+
const escapeSt = `${unicodeEscape(ESC)}${unicodeEscape(0x5c)}`;
|
|
191
|
+
const terminator =
|
|
192
|
+
`(?:${charClass(consumed)}|${escapeSt}` +
|
|
193
|
+
`|(?=${charClass(abortBefore)})|(?![\\s\\S]))`;
|
|
194
|
+
const stringArm = `(?:${introducer7Bit}|${charClass(c1Introducers)})${body}${terminator}`;
|
|
195
|
+
// The negative lookahead pins the intro run MAXIMAL — which is what the
|
|
196
|
+
// scanner's `while` loop does — and in doing so removes the only place the
|
|
197
|
+
// two quantifiers could repartition (`;` is in both classes), so this cannot
|
|
198
|
+
// backtrack super-linearly the way an unbounded `[…;…]*[…;…]*` would.
|
|
199
|
+
const csiArm =
|
|
200
|
+
`${charClass([ESC, CSI_C1])}${CSI_INTRO_CLASS}*(?!${CSI_INTRO_CLASS})` +
|
|
201
|
+
`${CSI_PARAM_CLASS}*${CSI_FINAL_CLASS}`;
|
|
202
|
+
// The string arm runs FIRST for the same reason it does in scanAnsi: `P` is
|
|
203
|
+
// also a CSI final byte, so a CSI-first alternation takes `ESC P` alone and
|
|
204
|
+
// leaves the DCS body as visible text.
|
|
205
|
+
return `(?:${stringArm}|${csiArm})`;
|
|
206
|
+
})();
|
|
207
|
+
|
|
123
208
|
/** The seven things an introducer can turn out to be. */
|
|
124
209
|
export const TOKEN_KIND = Object.freeze({
|
|
125
210
|
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
@@ -230,7 +315,9 @@ export function orphanKindFor(ch, next) {
|
|
|
230
315
|
* bound one stray `ESC ]` deleted every later line to end of input, so on a
|
|
231
316
|
* consumer that reads the strip as a RECORD (a model, not a display) one
|
|
232
317
|
* introducer blinded the whole tail behind a clean-looking prefix. The
|
|
233
|
-
* break survives; the payload after it on the same line is dropped.
|
|
318
|
+
* break survives; the payload after it on the same line is dropped. This is
|
|
319
|
+
* what makes the layer-wide invariant hold — no token of any kind spans a
|
|
320
|
+
* line break, so a strip NEVER removes a newline (test/layer1-ansi).
|
|
234
321
|
* 4. end of input, for a genuinely unterminated string with no line break:
|
|
235
322
|
* fail closed and drop everything from the introducer on, so no body
|
|
236
323
|
* survives.
|
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
|
|
package/types/ansi.d.mts
CHANGED
|
@@ -65,6 +65,28 @@ export const CONTROL_INTRODUCER_SOURCE: string;
|
|
|
65
65
|
* and the regex can no longer describe different languages.
|
|
66
66
|
*/
|
|
67
67
|
export const SGR_RE: RegExp;
|
|
68
|
+
/**
|
|
69
|
+
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
70
|
+
* artifact for a consumer that cannot run this module.
|
|
71
|
+
*
|
|
72
|
+
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
73
|
+
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
74
|
+
* it is linear by construction where the regex form has to carry an explicit
|
|
75
|
+
* guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
|
|
76
|
+
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
77
|
+
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
78
|
+
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
79
|
+
* set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
|
|
80
|
+
* hand-written spellings of one grammar.
|
|
81
|
+
*
|
|
82
|
+
* Every construct here is common to JS and Python `re` with NO flags —
|
|
83
|
+
* `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
|
|
84
|
+
* `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
|
|
85
|
+
* — so ONE pattern string is what both engines read.
|
|
86
|
+
* `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
|
|
87
|
+
* corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
|
|
88
|
+
*/
|
|
89
|
+
export const ESCAPE_SEQUENCE_SOURCE: string;
|
|
68
90
|
/** The seven things an introducer can turn out to be. */
|
|
69
91
|
export const TOKEN_KIND: Readonly<{
|
|
70
92
|
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The instruction files Claude Code loads from the directories ABOVE `dir`:
|
|
3
|
-
* walking up to the filesystem root, `
|
|
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
|