agent-sanitizer 2.57.5 → 2.58.1

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 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 { scanInstructionFiles, cleanFile } from "agent-sanitizer/instructions";
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
@@ -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 } = await import("../src/instructions.mjs");
174
- const opts = typeof req.cwd === "string" ? { cwd: req.cwd } : {};
175
- return { findings: scanInstructionFiles(globs, opts) };
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), and this table is
39
- // pure data with no fs access of its own the fs calls below are this
40
- // module's, not a copy of the SessionStart hook's target-discovery glue.
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
- ...globSync([...CLAUDE_LAUNCH_GLOBS], {
363
- cwd: dir,
364
- exclude: excludeFromContextScan,
365
- }).map((name) => join(dir, name)),
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
- globSync([...USER_GLOBAL_EVENT_NAMED_GLOBS], {
439
- cwd: configDir,
440
- exclude: excludeFromContextScan,
441
- }).map((name) => join(configDir, name)),
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.57.5",
3
+ "version": "2.58.1",
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": {
@@ -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
- * @param {string} entry a bare entry name or a path relative to the scan root
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
- * A walker calls this with both bare names and root-relative paths, so it must
260
- * answer for either; a bare name carries no `.claude` context and is judged only
261
- * against `node_modules`.
262
- * @param {string} entry a bare entry name or a path relative to the scan root
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) {
@@ -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 excludeFromContextScan} to prune either walk)
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 WALK, which is where a wide glob's cost actually is —
353
- * a pattern that merely fails to match a bulk directory still pays to read it.
354
- * It is composed with, never replaces, the unconditional `node_modules` prune:
355
- * a caller narrowing the scan must not be able to widen it into a dependency
356
- * tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
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 name of globSync(pattern, {
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
- * @param {string} entry a bare entry name or a path relative to the scan root
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
- * A walker calls this with both bare names and root-relative paths, so it must
53
- * answer for either; a bare name carries no `.claude` context and is judged only
54
- * against `node_modules`.
55
- * @param {string} entry a bare entry name or a path relative to the scan root
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;
@@ -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 WALK, which is where a wide glob's cost actually is —
45
- * a pattern that merely fails to match a bulk directory still pays to read it.
46
- * It is composed with, never replaces, the unconditional `node_modules` prune:
47
- * a caller narrowing the scan must not be able to widen it into a dependency
48
- * tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
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";