agent-sanitizer 2.57.4 → 2.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/THREAT-MODEL.md +18 -0
- package/bin/sanitize-cli.mjs +14 -3
- package/claude-hooks/lib/invisible-alert.mjs +18 -13
- package/package.json +1 -1
- package/src/claude-context.mjs +10 -6
- package/src/instructions.mjs +47 -16
- package/src/repo-scope.mjs +240 -0
- package/types/claude-context.d.mts +9 -5
- package/types/instructions.d.mts +24 -5
- package/types/repo-scope.d.mts +58 -0
- package/types/src/ansi.d.mts +176 -0
- package/types/src/cf-charset.d.mts +14 -0
- package/types/src/claude-context.d.mts +9 -5
- package/types/src/instructions.d.mts +170 -0
- package/types/src/invisible.d.mts +217 -0
- package/types/src/joining-type.d.mts +32 -0
- package/types/src/repo-scope.d.mts +58 -0
- package/types/src/standardized-variants.d.mts +22 -0
package/README.md
CHANGED
|
@@ -460,9 +460,16 @@ normalizeConfusables(
|
|
|
460
460
|
{ scan: (t) => myHomoglyphEngine.scan(t) }, // override the default namespace-guard engine
|
|
461
461
|
);
|
|
462
462
|
|
|
463
|
-
import {
|
|
463
|
+
import {
|
|
464
|
+
contextScanExclude,
|
|
465
|
+
scanInstructionFiles,
|
|
466
|
+
cleanFile,
|
|
467
|
+
} from "agent-sanitizer/instructions";
|
|
464
468
|
const findings = scanInstructionFiles(["CLAUDE.md", "**/SKILL.md"], {
|
|
465
469
|
cwd: projectDir,
|
|
470
|
+
// Skip what git says is not this checkout's source: ignored directories and
|
|
471
|
+
// nested worktrees. Ignored FILES (CLAUDE.local.md) are still scanned.
|
|
472
|
+
exclude: contextScanExclude(projectDir),
|
|
466
473
|
});
|
|
467
474
|
for (const { file } of findings) cleanFile(`${projectDir}/${file}`);
|
|
468
475
|
|
|
@@ -542,6 +549,9 @@ verdicts through the bundled CLI, so no second implementation can drift. An `op`
|
|
|
542
549
|
field selects the entry point (default `sanitize`); the self-contained ones —
|
|
543
550
|
`sanitizeText`, `classifyPrompt`, `scanInstructionFiles`, `cleanFile` — are
|
|
544
551
|
bridged, while entry points taking a JS callback have no wire form. Bridged
|
|
552
|
+
`scanInstructionFiles` walks the whole-tree scope: it skips `node_modules`, the
|
|
553
|
+
directories git ignores wholesale and any nested worktree, since a finding from
|
|
554
|
+
one of those names a file the scanned checkout does not own. Bridged
|
|
545
555
|
`sanitizeText` runs Layers 1–3 only: no secret redaction (Layer 4), no injection
|
|
546
556
|
filtering (Layer 5), and—since the bridge never wires `sgrCarveOut`—Layer 1's
|
|
547
557
|
findings are never downgraded, so `notes` carries only the Layer-2/3 tiers and
|
package/THREAT-MODEL.md
CHANGED
|
@@ -427,6 +427,24 @@ context KINDS, each row naming what loads it and when.
|
|
|
427
427
|
sanitizer when a tool reads one. Covering those eagerly means the whole-tree
|
|
428
428
|
walk at session start that this split exists to remove.
|
|
429
429
|
|
|
430
|
+
Both walks skip what git says is not the checkout's own source: `node_modules`,
|
|
431
|
+
the non-context children of a `.claude/` directory, and every nested worktree
|
|
432
|
+
(`contextScanExclude`, `src/repo-scope.mjs`). The whole-tree walk also skips the
|
|
433
|
+
directories git ignores wholesale; **the launch walk deliberately does not**.
|
|
434
|
+
`.gitignore` is repo-controlled, so honouring it there would let a repo hide a
|
|
435
|
+
planted `.claude/skills/…/SKILL.md` from the one scan that runs before the first
|
|
436
|
+
tool call — whereas anything the whole-tree walk skips is still scanned by
|
|
437
|
+
`scan-loaded-instructions` at the moment the host loads it. Ignored FILES are
|
|
438
|
+
never pruned in either walk: `CLAUDE.local.md` is gitignored by convention and
|
|
439
|
+
loads as model context at launch.
|
|
440
|
+
|
|
441
|
+
Asking git costs a subprocess inside the directory being scanned, and that
|
|
442
|
+
directory is the untrusted party here — git reads its `.git/config`, and
|
|
443
|
+
`ls-files` runs `core.fsmonitor` as a command to refresh the index, which
|
|
444
|
+
`safe.directory` does not cover for a planted config owned by the same uid.
|
|
445
|
+
`runGit` pins `core.fsmonitor=false` on every query so a scan of a hostile
|
|
446
|
+
checkout cannot become code execution.
|
|
447
|
+
|
|
430
448
|
The lazy half cannot block: the file is already in context when it fires, so its
|
|
431
449
|
neutralization is to strip the payload from disk (so no reload re-reads it) and
|
|
432
450
|
tell the model to treat what it just read as untrusted data. Auto-cleaning is
|
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* sanitizeText { text, html?, exfilScan?, flagDigestValues? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
19
19
|
* classifyPrompt { text } -> { action, reason? }
|
|
20
20
|
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
|
|
21
|
+
* Skips `node_modules`, the directories git ignores wholesale, and any
|
|
22
|
+
* nested worktree — none of them is the scanned checkout's own source.
|
|
21
23
|
* cleanFile { path } -> { changed }
|
|
22
24
|
*
|
|
23
25
|
* A failure response is `{ "error": string }`. Two modes, same binary:
|
|
@@ -170,9 +172,18 @@ export const OPS = {
|
|
|
170
172
|
// (matches the fail-loud contract every other typed field here follows).
|
|
171
173
|
if ("cwd" in req && typeof req.cwd !== "string")
|
|
172
174
|
throw new Error("request.cwd must be a string");
|
|
173
|
-
const { scanInstructionFiles } =
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
const { contextScanExclude, scanInstructionFiles } =
|
|
176
|
+
await import("../src/instructions.mjs");
|
|
177
|
+
const cwd = typeof req.cwd === "string" ? req.cwd : process.cwd();
|
|
178
|
+
// Whole-tree scope: a gitignored build directory and a nested worktree are
|
|
179
|
+
// not this checkout's source, so a finding from one names a file the caller
|
|
180
|
+
// does not own — and `cleanFile` would rewrite it.
|
|
181
|
+
return {
|
|
182
|
+
findings: scanInstructionFiles(globs, {
|
|
183
|
+
cwd,
|
|
184
|
+
exclude: contextScanExclude(cwd),
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
176
187
|
},
|
|
177
188
|
|
|
178
189
|
/** @param {Record<string, unknown>} req */
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
14
|
existsSync,
|
|
15
|
-
globSync,
|
|
16
15
|
lstatSync,
|
|
17
16
|
mkdirSync,
|
|
18
17
|
readdirSync,
|
|
@@ -35,16 +34,17 @@ import {
|
|
|
35
34
|
writeSentinelFile,
|
|
36
35
|
} from "./hook-io.mjs";
|
|
37
36
|
// Relative, like scan-invisible-chars.mjs's own import of this module: the
|
|
38
|
-
// launch scope is hook POLICY (see src/claude-context.mjs)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
37
|
+
// launch scope is hook POLICY (see src/claude-context.mjs). Sharing the walk
|
|
38
|
+
// itself is what keeps the prune's entry spelling from having two definitions —
|
|
39
|
+
// an exclude predicate is only as precise as the entries it is handed.
|
|
41
40
|
import {
|
|
42
41
|
ancestorInstructionFiles,
|
|
43
42
|
announcedByInstructionsLoaded,
|
|
44
43
|
CLAUDE_LAUNCH_GLOBS,
|
|
45
|
-
excludeFromContextScan,
|
|
46
44
|
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
47
45
|
} from "../../src/claude-context.mjs";
|
|
46
|
+
import { contextScanExclude } from "../../src/repo-scope.mjs";
|
|
47
|
+
import { walkContextGlobs } from "../../src/instructions.mjs";
|
|
48
48
|
|
|
49
49
|
// Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
|
|
50
50
|
// permissionDecisionReason. The WELL-FORMED composition, not the bare applyLayer1:
|
|
@@ -359,10 +359,14 @@ export function launchEmptyFile(sessionId) {
|
|
|
359
359
|
*/
|
|
360
360
|
export function launchInstructionFiles(dir) {
|
|
361
361
|
return [
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
// This walk is the only thing covering launch-time ingress — nothing
|
|
363
|
+
// rescans what it skips — so it takes the posture that refuses to let a
|
|
364
|
+
// repo's own `.gitignore` narrow it (see contextScanExclude).
|
|
365
|
+
...walkContextGlobs(
|
|
366
|
+
[...CLAUDE_LAUNCH_GLOBS],
|
|
367
|
+
dir,
|
|
368
|
+
contextScanExclude(dir, { ignoredDirs: false }),
|
|
369
|
+
),
|
|
366
370
|
// Filtered, unlike the glob's matches: almost every parent directory holds
|
|
367
371
|
// neither memory file, so the unfiltered chain would file ~10 phantom
|
|
368
372
|
// targets per session into scan-invisible-chars.mjs's operator-facing
|
|
@@ -435,10 +439,11 @@ function announcedLaunchFiles(dir) {
|
|
|
435
439
|
function userGlobalLaunchHasContent(env = process.env) {
|
|
436
440
|
const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
437
441
|
return anyFileHasBytes(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
+
walkContextGlobs(
|
|
443
|
+
[...USER_GLOBAL_EVENT_NAMED_GLOBS],
|
|
444
|
+
configDir,
|
|
445
|
+
contextScanExclude(configDir, { ignoredDirs: false }),
|
|
446
|
+
),
|
|
442
447
|
);
|
|
443
448
|
}
|
|
444
449
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.58.0",
|
|
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
|
@@ -222,11 +222,15 @@ export function isInsideDir(dir, file) {
|
|
|
222
222
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
223
223
|
* function so the name is spelled once, and so the two predicates that need it
|
|
224
224
|
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
225
|
-
*
|
|
225
|
+
*
|
|
226
|
+
* The LAST segment is what it reads: a dependency tree nested under a workspace
|
|
227
|
+
* package is the same dependency tree, and an entry naming one arrives as the
|
|
228
|
+
* path `packages/a/node_modules`, never as a bare name.
|
|
229
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
226
230
|
* @returns {boolean}
|
|
227
231
|
*/
|
|
228
232
|
export function excludeNodeModules(entry) {
|
|
229
|
-
return entry === "node_modules";
|
|
233
|
+
return entry.split(/[/\\]/).at(-1) === "node_modules";
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
// The path segments below one `.claude` directory in `path`, or null when it
|
|
@@ -256,10 +260,10 @@ function claudeTail(path, which) {
|
|
|
256
260
|
* context: a doubled-star segment does cross into a dot directory when the
|
|
257
261
|
* pattern names one.
|
|
258
262
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* @param {string} entry a
|
|
263
|
+
* Entries are paths relative to the scan root, so a top-level one is a bare
|
|
264
|
+
* name: it carries no `.claude` context and is judged only against
|
|
265
|
+
* `node_modules`.
|
|
266
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
263
267
|
* @returns {boolean}
|
|
264
268
|
*/
|
|
265
269
|
export function excludeFromContextScan(entry) {
|
package/src/instructions.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* Claude Code's own convention is re-exported below
|
|
16
16
|
* ({@link CLAUDE_INSTRUCTION_GLOBS} for a whole tree,
|
|
17
17
|
* {@link CLAUDE_LAUNCH_GLOBS} + {@link ancestorInstructionFiles} for just what a
|
|
18
|
-
* session loads at launch, {@link
|
|
18
|
+
* session loads at launch, {@link contextScanExclude} to prune either walk down
|
|
19
|
+
* to what git says is this checkout's own source)
|
|
19
20
|
* so a caller that wants it takes the hooks' exact scope rather than
|
|
20
21
|
* approximating it — see ./claude-context.mjs.
|
|
21
22
|
*/
|
|
@@ -63,6 +64,12 @@ export {
|
|
|
63
64
|
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
64
65
|
} from "./claude-context.mjs";
|
|
65
66
|
|
|
67
|
+
// The prune a caller should actually pass: the static scope above, plus the
|
|
68
|
+
// directories git says are not this checkout's source. `excludeFromContextScan`
|
|
69
|
+
// stays exported as the static half alone, for a caller with no repository to
|
|
70
|
+
// ask.
|
|
71
|
+
export { contextScanExclude } from "./repo-scope.mjs";
|
|
72
|
+
|
|
66
73
|
// Prefix on any decoded tag-character payload. The decoded text is
|
|
67
74
|
// attacker-controlled and flows into the scan report, which itself reaches model
|
|
68
75
|
// context — so it must be framed as DATA, never re-presented as a live
|
|
@@ -336,6 +343,37 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
|
336
343
|
);
|
|
337
344
|
}
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Every `globs` match under `cwd`, as an absolute path, with `node_modules` and
|
|
348
|
+
* whatever `exclude` rejects pruned from the WALK rather than filtered from its
|
|
349
|
+
* results — which is where a wide glob's cost actually is.
|
|
350
|
+
*
|
|
351
|
+
* `withFileTypes` is what makes the prune ANSWERABLE. Without it the walker
|
|
352
|
+
* calls `exclude` once with an entry's bare name and again with its
|
|
353
|
+
* root-relative path, so a predicate holding `build` cannot tell a top-level
|
|
354
|
+
* `build/` from a tracked `src/build/` and prunes both; under an absolute
|
|
355
|
+
* pattern it is handed absolute paths and matches neither. A Dirent carries an
|
|
356
|
+
* absolute `parentPath`, which normalizes to exactly one root-relative,
|
|
357
|
+
* `/`-separated entry per walked directory whatever shape the pattern has.
|
|
358
|
+
* @param {string[]} globs
|
|
359
|
+
* @param {string} cwd
|
|
360
|
+
* @param {(entry: string) => boolean} [exclude]
|
|
361
|
+
* @returns {string[]} absolute paths, one match per element
|
|
362
|
+
*/
|
|
363
|
+
export function walkContextGlobs(globs, cwd, exclude) {
|
|
364
|
+
const root = resolve(cwd);
|
|
365
|
+
/** @param {import("node:fs").Dirent} dirent @returns {string} */
|
|
366
|
+
const absolute = (dirent) => join(dirent.parentPath, dirent.name);
|
|
367
|
+
return globSync(globs, {
|
|
368
|
+
cwd,
|
|
369
|
+
withFileTypes: true,
|
|
370
|
+
exclude: (dirent) => {
|
|
371
|
+
const entry = relative(root, absolute(dirent)).split(sep).join("/");
|
|
372
|
+
return excludeNodeModules(entry) || (exclude?.(entry) ?? false);
|
|
373
|
+
},
|
|
374
|
+
}).map(absolute);
|
|
375
|
+
}
|
|
376
|
+
|
|
339
377
|
/**
|
|
340
378
|
* Expand `globs` (relative to `cwd`) to absolute file paths, skipping
|
|
341
379
|
* `node_modules`. The glob set is the caller's instruction-file convention.
|
|
@@ -349,11 +387,11 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
|
349
387
|
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
350
388
|
* project.
|
|
351
389
|
*
|
|
352
|
-
* `exclude` prunes the
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
390
|
+
* `exclude` prunes the walk via {@link walkContextGlobs}, so it is handed one
|
|
391
|
+
* root-relative, `/`-separated path per entry. It is composed with, never
|
|
392
|
+
* replaces, the unconditional `node_modules` prune: a caller narrowing the scan
|
|
393
|
+
* must not be able to widen it into a dependency tree. Pass
|
|
394
|
+
* {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
357
395
|
* @param {string[]} globs
|
|
358
396
|
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
359
397
|
* @returns {string[]}
|
|
@@ -365,19 +403,12 @@ export function findInstructionFiles(
|
|
|
365
403
|
const literalRoot = resolve(cwd);
|
|
366
404
|
const realRoot = realpathSync(literalRoot);
|
|
367
405
|
const seen = new Set();
|
|
406
|
+
// One pattern at a time, so a containment failure can name the glob that
|
|
407
|
+
// reached outside the tree.
|
|
368
408
|
for (const pattern of globs)
|
|
369
|
-
for (const
|
|
370
|
-
cwd,
|
|
371
|
-
exclude: (entry) =>
|
|
372
|
-
excludeNodeModules(entry) || (exclude?.(entry) ?? false),
|
|
373
|
-
})) {
|
|
374
|
-
// globSync returns absolute paths verbatim for an absolute pattern and
|
|
375
|
-
// cwd-relative names otherwise; joining an already-absolute name would
|
|
376
|
-
// double the prefix into a nonexistent path (the absolute-glob miss bug).
|
|
377
|
-
const absPath = isAbsolute(name) ? name : join(cwd, name);
|
|
409
|
+
for (const absPath of walkContextGlobs([pattern], cwd, exclude))
|
|
378
410
|
if (keepContained(absPath, realRoot, literalRoot, pattern))
|
|
379
411
|
seen.add(absPath);
|
|
380
|
-
}
|
|
381
412
|
return [...seen];
|
|
382
413
|
}
|
|
383
414
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What git says is NOT this checkout's own source, as a prune set for an
|
|
3
|
+
* instruction-file walk: the directories it ignores wholesale, and the linked
|
|
4
|
+
* worktrees nested inside the scan root. A context scan that walks those pays
|
|
5
|
+
* for a build tree it will never load from, and reports — and on the auto-clean
|
|
6
|
+
* path REWRITES — another branch's copy of a file this checkout does not own.
|
|
7
|
+
*
|
|
8
|
+
* Git is asked rather than `.gitignore` parsed: it owns that grammar, nested
|
|
9
|
+
* files, `info/exclude` and `core.excludesFile` included, and the answer is one
|
|
10
|
+
* subprocess away.
|
|
11
|
+
*
|
|
12
|
+
* Every entry is a DIRECTORY. `git ls-files -o -i` also lists ignored FILES, and
|
|
13
|
+
* discarding those is what keeps `CLAUDE.local.md` — gitignored by convention,
|
|
14
|
+
* and loaded as model context at launch — inside every scan.
|
|
15
|
+
*
|
|
16
|
+
* Kept out of ./claude-context.mjs, which promises a dependency-free data module
|
|
17
|
+
* with no filesystem of its own.
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { realpathSync } from "node:fs";
|
|
21
|
+
import { relative, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { excludeFromContextScan, isInsideDir } from "./claude-context.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How git is asked. Injectable so a test can drive the failure paths, which no
|
|
27
|
+
* filesystem state can force.
|
|
28
|
+
* @typedef {(file: string, args: string[], cwd: string) => string} GitRun
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Both bounds exist so a scan cannot hang or balloon on a pathological repo:
|
|
32
|
+
// past either one the query is abandoned and the walk keeps its wider,
|
|
33
|
+
// pre-prune scope (see askGit).
|
|
34
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
35
|
+
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The default {@link GitRun}: git's stdout, with its stderr dropped so a
|
|
39
|
+
* dubious-ownership complaint never lands in the operator's terminal from a
|
|
40
|
+
* scan that recovers from it anyway.
|
|
41
|
+
*
|
|
42
|
+
* `core.fsmonitor=false` is a security pin, not a tuning knob: the scanned
|
|
43
|
+
* checkout is the untrusted party here, `ls-files` runs that config value as a
|
|
44
|
+
* COMMAND to refresh the index, and `safe.directory` does not cover a planted
|
|
45
|
+
* `.git/config` owned by the same uid. Without it, "scan this directory for
|
|
46
|
+
* hidden-Unicode payloads" is arbitrary code execution.
|
|
47
|
+
* @type {GitRun}
|
|
48
|
+
*/
|
|
49
|
+
const runGit = (file, args, cwd) =>
|
|
50
|
+
execFileSync(file, ["-c", "core.fsmonitor=false", ...args], {
|
|
51
|
+
cwd,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
54
|
+
timeout: GIT_TIMEOUT_MS,
|
|
55
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `git <args>` in `dir`, or null when git cannot answer — not a repo, git
|
|
60
|
+
* absent, dubious ownership, a timeout, output past `maxBuffer`.
|
|
61
|
+
*
|
|
62
|
+
* The one recovery this module needs, and it is what keeps the prune from ever
|
|
63
|
+
* being a new way for a session to fail: null degrades the caller to the WIDER
|
|
64
|
+
* scope it walked before this prune existed. A throw with neither an errno nor
|
|
65
|
+
* an exit status is not git refusing, it is a bug here, and it propagates.
|
|
66
|
+
* @param {GitRun} run
|
|
67
|
+
* @param {string[]} args
|
|
68
|
+
* @param {string} dir
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
function askGit(run, args, dir) {
|
|
72
|
+
try {
|
|
73
|
+
return run("git", args, dir);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const spawned = /** @type {NodeJS.ErrnoException & {status?: number}} */ (
|
|
76
|
+
err
|
|
77
|
+
);
|
|
78
|
+
if (spawned.code === undefined && spawned.status === undefined) throw err;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `-z` because the newline-framed form cannot represent a worktree whose path
|
|
84
|
+
// contains a newline: the record truncates mid-path, and both readers below
|
|
85
|
+
// then name a directory that does not exist. It needs git >= 2.36, and an
|
|
86
|
+
// older git rejects the flag rather than mis-answering — the prune degrades
|
|
87
|
+
// through askGit to its wider pre-prune scope, and the teardown guard reaches
|
|
88
|
+
// its own entrypoint catch.
|
|
89
|
+
const WORKTREE_LIST_ARGS = Object.freeze([
|
|
90
|
+
"worktree",
|
|
91
|
+
"list",
|
|
92
|
+
"--porcelain",
|
|
93
|
+
"-z",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether `attrs` carries `name`, as a bare flag or with a value after it.
|
|
98
|
+
* @param {string[]} attrs @param {string} name @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
const hasAttribute = (attrs, name) =>
|
|
101
|
+
attrs.some((attr) => attr === name || attr.startsWith(`${name} `));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The linked worktrees in a `git worktree list --porcelain -z` dump, main and
|
|
105
|
+
* bare ones excluded — those are not removable, so their state is never at risk
|
|
106
|
+
* from a teardown command, and the main one is the scan root a prune must never
|
|
107
|
+
* swallow.
|
|
108
|
+
* @param {string} porcelain
|
|
109
|
+
* @returns {string[]} absolute worktree paths
|
|
110
|
+
*/
|
|
111
|
+
export function parseWorktreeList(porcelain) {
|
|
112
|
+
const paths = [];
|
|
113
|
+
// Attributes are NUL-TERMINATED and a record ends with the resulting empty
|
|
114
|
+
// attribute, so records split on a doubled NUL. The first is always the main
|
|
115
|
+
// worktree; `bare` marks a bare repo's. `prunable` marks one whose directory
|
|
116
|
+
// is already gone — it holds no work to lose, and asking git for its status
|
|
117
|
+
// would only spawn into a missing cwd.
|
|
118
|
+
for (const record of porcelain.split("\0\0").slice(1)) {
|
|
119
|
+
const attrs = record.split("\0");
|
|
120
|
+
if (hasAttribute(attrs, "bare") || hasAttribute(attrs, "prunable"))
|
|
121
|
+
continue;
|
|
122
|
+
const line = attrs.find((attr) => attr.startsWith("worktree "));
|
|
123
|
+
if (line) paths.push(line.slice("worktree ".length));
|
|
124
|
+
}
|
|
125
|
+
return paths;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The linked worktrees of the repo at `cwd`. The guard on `git worktree remove`
|
|
130
|
+
* asks this the same way the prune below does, so one parser answers for both.
|
|
131
|
+
* @param {string} cwd
|
|
132
|
+
* @param {GitRun} run
|
|
133
|
+
* @returns {string[]} absolute worktree paths
|
|
134
|
+
*/
|
|
135
|
+
export function linkedWorktrees(cwd, run) {
|
|
136
|
+
return parseWorktreeList(run("git", [...WORKTREE_LIST_ARGS], cwd));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The directories under `dir` that git ignores wholesale. `--directory`
|
|
141
|
+
* collapses each to a single trailing-slash entry, and that slash is the only
|
|
142
|
+
* thing separating an ignored directory from an ignored FILE in this output —
|
|
143
|
+
* dropping the files is what keeps a gitignored instruction file scannable.
|
|
144
|
+
*
|
|
145
|
+
* `-z` because without it git C-quotes any path with a space, a quote or a
|
|
146
|
+
* non-ASCII byte under `core.quotePath`, and the prune would silently miss
|
|
147
|
+
* exactly those directories.
|
|
148
|
+
* @param {string} dir
|
|
149
|
+
* @param {GitRun} run
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
function ignoredDirectories(dir, run) {
|
|
153
|
+
const out = askGit(
|
|
154
|
+
run,
|
|
155
|
+
["ls-files", "-o", "-i", "--directory", "--exclude-standard", "-z"],
|
|
156
|
+
dir,
|
|
157
|
+
);
|
|
158
|
+
if (out === null) return [];
|
|
159
|
+
return out
|
|
160
|
+
.split("\0")
|
|
161
|
+
.filter((entry) => entry.endsWith("/"))
|
|
162
|
+
.map((entry) => entry.slice(0, -1));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The worktrees git has registered that live inside `dir`, as paths relative to
|
|
167
|
+
* it. A nested checkout is not ignored by default, so nothing but git's own
|
|
168
|
+
* registry can tell it apart from an ordinary subdirectory.
|
|
169
|
+
* @param {string} dir
|
|
170
|
+
* @param {GitRun} run
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
function nestedWorktrees(dir, run) {
|
|
174
|
+
const out = askGit(run, [...WORKTREE_LIST_ARGS], dir);
|
|
175
|
+
if (out === null) return [];
|
|
176
|
+
// git reports PHYSICAL paths, so a scan root reached through a symlink only
|
|
177
|
+
// ever matches once both sides are canonical. The relative tail it yields is
|
|
178
|
+
// valid under the literal spelling too, which is what the walker's entries
|
|
179
|
+
// are relative to. Asked after the git call so a missing `dir` still fails
|
|
180
|
+
// open through the spawn's ENOENT rather than throwing here.
|
|
181
|
+
const root = realpathSync(dir);
|
|
182
|
+
return parseWorktreeList(out)
|
|
183
|
+
.map((path) => resolve(path))
|
|
184
|
+
.filter((path) => isInsideDir(root, path))
|
|
185
|
+
.map((path) => relative(root, path).split(sep).join("/"));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// One prune set per scan root, because launchInstructionFiles runs on many tool
|
|
189
|
+
// calls and each would otherwise spawn git twice. Bypassed for an injected
|
|
190
|
+
// `run`, so a test never reads another test's answer. A worktree added
|
|
191
|
+
// mid-process is missed until the next process, which is the same staleness the
|
|
192
|
+
// walk's own glob already has.
|
|
193
|
+
/** @type {Map<string, Set<string>>} */
|
|
194
|
+
const pruneCache = new Map();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Directory paths a context scan of `dir` must not walk, relative to it and
|
|
198
|
+
* `/`-separated: every wholly-ignored directory (unless `ignoredDirs` is off)
|
|
199
|
+
* and every linked worktree nested inside it.
|
|
200
|
+
* @param {string} dir
|
|
201
|
+
* @param {{ ignoredDirs?: boolean, run?: GitRun }} [options]
|
|
202
|
+
* @returns {Set<string>}
|
|
203
|
+
*/
|
|
204
|
+
export function repoPrunedDirs(dir, { ignoredDirs = true, run } = {}) {
|
|
205
|
+
const key = `${resolve(dir)}\0${ignoredDirs}`;
|
|
206
|
+
const cached = run === undefined ? pruneCache.get(key) : undefined;
|
|
207
|
+
if (cached !== undefined) return cached;
|
|
208
|
+
const ask = run ?? runGit;
|
|
209
|
+
const pruned = new Set([
|
|
210
|
+
...(ignoredDirs ? ignoredDirectories(dir, ask) : []),
|
|
211
|
+
...nestedWorktrees(dir, ask),
|
|
212
|
+
]);
|
|
213
|
+
if (run === undefined) pruneCache.set(key, pruned);
|
|
214
|
+
return pruned;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The `exclude` predicate for an instruction-file walk of `dir`: the static
|
|
219
|
+
* context-scope prune, plus {@link repoPrunedDirs}.
|
|
220
|
+
*
|
|
221
|
+
* `ignoredDirs: false` is the LAUNCH scan's posture, and it is a security
|
|
222
|
+
* choice rather than a performance one: `.gitignore` is repo-controlled, so
|
|
223
|
+
* honouring it in the one scan that covers launch-time ingress would let a
|
|
224
|
+
* hostile repo hide a planted `.claude/skills/…/SKILL.md` from it by ignoring
|
|
225
|
+
* that directory. The whole-tree scan can honour it because anything it prunes
|
|
226
|
+
* is still scanned by scan-loaded-instructions at the moment the host loads it.
|
|
227
|
+
*
|
|
228
|
+
* The lookup is EXACT, so the walk must hand it the same spelling
|
|
229
|
+
* {@link repoPrunedDirs} uses — one root-relative, `/`-separated path per entry,
|
|
230
|
+
* which is what walkContextGlobs normalizes to. A predicate that accepted a bare
|
|
231
|
+
* name as well would prune a tracked `src/build/` for a top-level ignored
|
|
232
|
+
* `build/`, splicing real instruction files out of the scan.
|
|
233
|
+
* @param {string} dir
|
|
234
|
+
* @param {{ ignoredDirs?: boolean, run?: GitRun }} [options]
|
|
235
|
+
* @returns {(entry: string) => boolean}
|
|
236
|
+
*/
|
|
237
|
+
export function contextScanExclude(dir, options = {}) {
|
|
238
|
+
const pruned = repoPrunedDirs(dir, options);
|
|
239
|
+
return (entry) => excludeFromContextScan(entry) || pruned.has(entry);
|
|
240
|
+
}
|
|
@@ -33,7 +33,11 @@ export function isInsideDir(dir: string, file: string): boolean;
|
|
|
33
33
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
34
34
|
* function so the name is spelled once, and so the two predicates that need it
|
|
35
35
|
* (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
|
|
36
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* The LAST segment is what it reads: a dependency tree nested under a workspace
|
|
38
|
+
* package is the same dependency tree, and an entry naming one arrives as the
|
|
39
|
+
* path `packages/a/node_modules`, never as a bare name.
|
|
40
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
37
41
|
* @returns {boolean}
|
|
38
42
|
*/
|
|
39
43
|
export function excludeNodeModules(entry: string): boolean;
|
|
@@ -49,10 +53,10 @@ export function excludeNodeModules(entry: string): boolean;
|
|
|
49
53
|
* context: a doubled-star segment does cross into a dot directory when the
|
|
50
54
|
* pattern names one.
|
|
51
55
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @param {string} entry a
|
|
56
|
+
* Entries are paths relative to the scan root, so a top-level one is a bare
|
|
57
|
+
* name: it carries no `.claude` context and is judged only against
|
|
58
|
+
* `node_modules`.
|
|
59
|
+
* @param {string} entry a path relative to the scan root, `/`-separated
|
|
56
60
|
* @returns {boolean}
|
|
57
61
|
*/
|
|
58
62
|
export function excludeFromContextScan(entry: string): boolean;
|
package/types/instructions.d.mts
CHANGED
|
@@ -28,6 +28,24 @@ export function scanText(content: string): Array<{
|
|
|
28
28
|
method: string;
|
|
29
29
|
decoded: string;
|
|
30
30
|
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Every `globs` match under `cwd`, as an absolute path, with `node_modules` and
|
|
33
|
+
* whatever `exclude` rejects pruned from the WALK rather than filtered from its
|
|
34
|
+
* results — which is where a wide glob's cost actually is.
|
|
35
|
+
*
|
|
36
|
+
* `withFileTypes` is what makes the prune ANSWERABLE. Without it the walker
|
|
37
|
+
* calls `exclude` once with an entry's bare name and again with its
|
|
38
|
+
* root-relative path, so a predicate holding `build` cannot tell a top-level
|
|
39
|
+
* `build/` from a tracked `src/build/` and prunes both; under an absolute
|
|
40
|
+
* pattern it is handed absolute paths and matches neither. A Dirent carries an
|
|
41
|
+
* absolute `parentPath`, which normalizes to exactly one root-relative,
|
|
42
|
+
* `/`-separated entry per walked directory whatever shape the pattern has.
|
|
43
|
+
* @param {string[]} globs
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @param {(entry: string) => boolean} [exclude]
|
|
46
|
+
* @returns {string[]} absolute paths, one match per element
|
|
47
|
+
*/
|
|
48
|
+
export function walkContextGlobs(globs: string[], cwd: string, exclude?: (entry: string) => boolean): string[];
|
|
31
49
|
/**
|
|
32
50
|
* Expand `globs` (relative to `cwd`) to absolute file paths, skipping
|
|
33
51
|
* `node_modules`. The glob set is the caller's instruction-file convention.
|
|
@@ -41,11 +59,11 @@ export function scanText(content: string): Array<{
|
|
|
41
59
|
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
42
60
|
* project.
|
|
43
61
|
*
|
|
44
|
-
* `exclude` prunes the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
62
|
+
* `exclude` prunes the walk via {@link walkContextGlobs}, so it is handed one
|
|
63
|
+
* root-relative, `/`-separated path per entry. It is composed with, never
|
|
64
|
+
* replaces, the unconditional `node_modules` prune: a caller narrowing the scan
|
|
65
|
+
* must not be able to widen it into a dependency tree. Pass
|
|
66
|
+
* {@link excludeFromContextScan} to take Claude Code's own scope.
|
|
49
67
|
* @param {string[]} globs
|
|
50
68
|
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
51
69
|
* @returns {string[]}
|
|
@@ -148,4 +166,5 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
|
|
|
148
166
|
* @returns {boolean}
|
|
149
167
|
*/
|
|
150
168
|
export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
|
|
169
|
+
export { contextScanExclude } from "./repo-scope.mjs";
|
|
151
170
|
export { ancestorInstructionFiles, announcedByInstructionsLoaded, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan, USER_GLOBAL_EVENT_NAMED_GLOBS } from "./claude-context.mjs";
|