agent-sanitizer 2.24.2 → 2.26.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.
@@ -31,6 +31,18 @@ import {
31
31
  } from "./lib/invisible-alert.mjs";
32
32
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
33
33
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
34
+ // Relative, not the `agent-sanitizer` specifier every other engine import uses:
35
+ // this is the scan's SCOPE, which is hook policy and must move with the hook.
36
+ // Routing it through the specifier would resolve it, in the shipped plugin
37
+ // bundle, against a PINNED older engine that does not export it — leaving the
38
+ // walk with undefined globs while believing it had scanned everything. The
39
+ // module is dependency-free data (see src/claude-context.mjs), so importing it
40
+ // statically carries none of the fail-open hazard lazyImport exists to cover.
41
+ import {
42
+ CLAUDE_CONTEXT_SUBDIRS,
43
+ CLAUDE_INSTRUCTION_GLOBS,
44
+ excludeFromContextScan,
45
+ } from "../src/claude-context.mjs";
34
46
 
35
47
  // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
36
48
  // hazard of a bare static npm import — here the instruction files would load
@@ -192,99 +204,27 @@ function decodeRun(run) {
192
204
  };
193
205
  }
194
206
 
195
- /**
196
- * The `.claude/` subdirectories whose markdown Claude Code loads as model
197
- * context. This is a WHITELIST, and that is the point: `.claude/` is also where
198
- * tooling parks bulk data that is never loaded as context — `worktrees/`
199
- * (entire checked-out copies of the repo), plus caches, transcripts and
200
- * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
201
- * populated worktrees that is thousands of files READ at every session start:
202
- * one report put it at 30 seconds of blocked startup, paid for scanning files
203
- * that cannot reach the model.
204
- *
205
- * A whitelist, not a `worktrees` denylist, because the failure modes are not
206
- * symmetric: an unlisted context directory costs a scan this hook was never
207
- * asked for anyway (the PostToolUse sanitizer still cleans those bytes when a
208
- * tool reads them), while an unlisted BULK directory silently costs every future
209
- * session its startup. Add an entry here when Claude Code starts loading a new
210
- * `.claude/` subdirectory as context.
211
- */
212
- export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
213
- "agents",
214
- "commands",
215
- "output-styles",
216
- "skills",
217
- ]);
218
-
219
- // The glob patterns for one `.claude` tree at `prefix` (empty for the project
220
- // root, a doubled-star segment for nested ones): its top-level markdown, plus the
221
- // whitelisted context subdirectories. Built once, from the one list above.
222
- /** @param {string} prefix @returns {string[]} */
223
- function claudeDirPatterns(prefix) {
224
- return [
225
- `${prefix}.claude/*.md`,
226
- ...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${prefix}.claude/${sub}/**/*.md`),
227
- ];
228
- }
229
-
230
- /**
231
- * Entries the walk must not descend into or return: `node_modules`, and every
232
- * child of a `.claude` directory that is not whitelisted context.
233
- *
234
- * The patterns alone would already refuse to MATCH those files, but globSync
235
- * calls this on directories as it walks and prunes the ones it rejects — which
236
- * is where the cost actually is. Without the prune, a `.claude/worktrees/`
237
- * holding a few repo checkouts is walked in full on every session start (and,
238
- * because a doubled-star segment does cross into a dot directory when the
239
- * pattern names one, a `.claude` NESTED inside a worktree was matched and
240
- * scanned as if it were this session's context).
241
- *
242
- * globSync calls this with both bare names and repo-relative paths, so it must
243
- * answer for either; a bare name carries no `.claude` context and is judged only
244
- * against `node_modules`.
245
- * @param {string} entry a bare entry name or a path relative to the scan root
246
- * @returns {boolean}
247
- */
248
- function excludeFromScan(entry) {
249
- if (entry === "node_modules") return true;
250
- const parts = entry.split(/[/\\]/);
251
- const claudeIndex = parts.indexOf(".claude");
252
- const tail = parts.slice(claudeIndex + 1);
253
- if (claudeIndex === -1 || tail.length === 0) return false;
254
- // `.claude/<file>.md` is context (a top-level note); anything else directly
255
- // under `.claude` must be a whitelisted subdirectory to be walked at all.
256
- if (tail.length === 1 && tail[0].endsWith(".md")) return false;
257
- return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
258
- }
259
-
260
207
  /**
261
208
  * Every file under `dir` that Claude Code loads as model context: the
262
- * subdirectory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and the
263
- * whitelisted `.claude/` markdown (see {@link CLAUDE_CONTEXT_SUBDIRS}). Claude
264
- * Code loads these on entry to their containing directory — a load path that
265
- * bypasses the PostToolUse sanitizer — so a payload planted in e.g.
266
- * `packages/foo/CLAUDE.md` reaches the model uncleaned unless it is scanned
267
- * here. Skips node_modules.
209
+ * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
210
+ * the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
211
+ * containing directory — a load path that bypasses the PostToolUse sanitizer —
212
+ * so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
213
+ * uncleaned unless it is scanned here.
268
214
  *
269
- * `**` does not descend into dot directories, so NESTED `.claude/` trees need
270
- * their own doubled-star-prefixed patterns: without them a directory-scoped skill at
271
- * `packages/foo/.claude/skills/x/SKILL.md` model context by the same load
272
- * path is never scanned. That same rule is why the root `.claude` needs no
273
- * separate walk: a leading doubled star matches zero segments, so the nested
274
- * patterns cover the root tree too.
215
+ * The scope itself which globs, and which directories the walk must prune
216
+ * is the library's {@link CLAUDE_INSTRUCTION_GLOBS} /
217
+ * {@link excludeFromContextScan}, so this hook and every other consumer read one
218
+ * list (see src/claude-context.mjs for why it is imported relatively rather than
219
+ * through the `agent-sanitizer` specifier the plugin bundle pins).
275
220
  * @param {string} dir
276
221
  * @returns {string[]}
277
222
  */
278
223
  function findInstructionFiles(dir) {
279
- return globSync(
280
- [
281
- "**/CLAUDE.md",
282
- "**/CLAUDE.local.md",
283
- "**/AGENTS.md",
284
- ...claudeDirPatterns("**/"),
285
- ],
286
- { cwd: dir, exclude: excludeFromScan },
287
- ).map((name) => join(dir, name));
224
+ return globSync([...CLAUDE_INSTRUCTION_GLOBS], {
225
+ cwd: dir,
226
+ exclude: excludeFromContextScan,
227
+ }).map((name) => join(dir, name));
288
228
  }
289
229
 
290
230
  // Scanner
@@ -324,6 +264,11 @@ function scanFile(filePath) {
324
264
  }
325
265
 
326
266
  export {
267
+ // Re-exported, never redefined: the scope this hook walks is the library's
268
+ // (src/claude-context.mjs), and a consumer that reads it off this hook must
269
+ // get that same list rather than a second copy that can drift.
270
+ CLAUDE_CONTEXT_SUBDIRS,
271
+ CLAUDE_INSTRUCTION_GLOBS,
327
272
  decodeRun,
328
273
  findInstructionFiles,
329
274
  scanFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.24.2",
3
+ "version": "2.26.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": {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * WHICH files an agent loads as model context, as data: the glob set and the
3
+ * walk-pruning predicate that together define "everything Claude Code reads as
4
+ * instructions, and nothing else".
5
+ *
6
+ * This is the SINGLE SOURCE for that scope. It used to live inside
7
+ * `claude-hooks/scan-invisible-chars.mjs`, which meant the SessionStart hook
8
+ * knew the answer and nobody else did: `src/instructions.mjs` takes
9
+ * caller-supplied globs by design (no agent's convention is baked into the
10
+ * engine), so the CLI, the Python port and every downstream fork spelled their
11
+ * own approximation of this list — and an approximation that drifts either
12
+ * scans bulk data that can never reach the model (the 30-second session start
13
+ * this whitelist exists to fix) or MISSES a context directory entirely, which
14
+ * is a silent hole in the one scan standing between a poisoned instruction file
15
+ * and a session that loads it.
16
+ *
17
+ * It is a standalone, dependency-free DATA module (like ./cf-charset.mjs) for
18
+ * two reasons: `src/instructions.mjs` re-exports it as the library's public
19
+ * door, and the hook imports it RELATIVELY — deliberately not through the
20
+ * `agent-sanitizer` specifier the plugin bundle pins to a published engine.
21
+ * This scope is hook POLICY, not engine behavior: it must ship and move with the
22
+ * hook that walks it, or a plugin built against an older pin would prune the
23
+ * wrong directories while believing it had scanned everything.
24
+ */
25
+
26
+ /**
27
+ * The `.claude/` subdirectories whose markdown Claude Code loads as model
28
+ * context. This is a WHITELIST, and that is the point: `.claude/` is also where
29
+ * tooling parks bulk data that is never loaded as context — `worktrees/`
30
+ * (entire checked-out copies of the repo), plus caches, transcripts and
31
+ * snapshots — and globbing `.claude/**` swept all of it in. On a repo with a few
32
+ * populated worktrees that is thousands of files READ at every session start:
33
+ * one report put it at 30 seconds of blocked startup, paid for scanning files
34
+ * that cannot reach the model.
35
+ *
36
+ * A whitelist, not a `worktrees` denylist, because the failure modes are not
37
+ * symmetric: an unlisted context directory costs a scan nobody asked for anyway
38
+ * (the PostToolUse sanitizer still cleans those bytes when a tool reads them),
39
+ * while an unlisted BULK directory silently costs every future session its
40
+ * startup. Add an entry here when Claude Code starts loading a new `.claude/`
41
+ * subdirectory as context.
42
+ */
43
+ export const CLAUDE_CONTEXT_SUBDIRS = Object.freeze([
44
+ "agents",
45
+ "commands",
46
+ "output-styles",
47
+ "skills",
48
+ ]);
49
+
50
+ // The glob patterns for one `.claude` tree at `prefix` (empty for the project
51
+ // root, a doubled-star segment for nested ones): its top-level markdown, plus the
52
+ // whitelisted context subdirectories. Built once, from the one list above.
53
+ /** @param {string} prefix @returns {string[]} */
54
+ function claudeDirPatterns(prefix) {
55
+ return [
56
+ `${prefix}.claude/*.md`,
57
+ ...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${prefix}.claude/${sub}/**/*.md`),
58
+ ];
59
+ }
60
+
61
+ /**
62
+ * Every glob whose matches Claude Code loads as model context: the
63
+ * per-directory instruction files (CLAUDE.md, CLAUDE.local.md, AGENTS.md) and
64
+ * the whitelisted `.claude/` markdown. Claude Code loads these on entry to their
65
+ * containing directory — a load path that bypasses the PostToolUse sanitizer —
66
+ * so a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model
67
+ * uncleaned unless something scans it here.
68
+ *
69
+ * `**` does not descend into dot directories, so NESTED `.claude/` trees need
70
+ * their own doubled-star-prefixed patterns: without them a directory-scoped
71
+ * skill at `packages/foo/.claude/skills/x/SKILL.md` — model context by the same
72
+ * load path — is never matched. That same rule is why the root `.claude` needs
73
+ * no separate entry: a leading doubled star matches zero segments, so the
74
+ * nested patterns cover the root tree too.
75
+ *
76
+ * Pair with {@link excludeFromContextScan}: the patterns alone already refuse to
77
+ * MATCH a bulk directory, but only pruning the WALK avoids paying to read it.
78
+ */
79
+ export const CLAUDE_INSTRUCTION_GLOBS = Object.freeze([
80
+ "**/CLAUDE.md",
81
+ "**/CLAUDE.local.md",
82
+ "**/AGENTS.md",
83
+ ...claudeDirPatterns("**/"),
84
+ ]);
85
+
86
+ /**
87
+ * The one directory no instruction-file walk ever descends into. Its own
88
+ * function so the name is spelled once, and so the two predicates that need it
89
+ * (a plain glob walk, and {@link excludeFromContextScan}) cannot disagree.
90
+ * @param {string} entry a bare entry name or a path relative to the scan root
91
+ * @returns {boolean}
92
+ */
93
+ export function excludeNodeModules(entry) {
94
+ return entry === "node_modules";
95
+ }
96
+
97
+ /**
98
+ * Entries a context scan must not descend into or return: `node_modules`, and
99
+ * every child of a `.claude` directory that is not whitelisted context.
100
+ *
101
+ * The globs alone would already refuse to MATCH those files, but a glob walker
102
+ * calls this on directories as it walks and prunes the ones it rejects — which
103
+ * is where the cost actually is. Without the prune, a `.claude/worktrees/`
104
+ * holding a few repo checkouts is walked in full on every session start (and,
105
+ * because a doubled-star segment does cross into a dot directory when the
106
+ * pattern names one, a `.claude` NESTED inside a worktree was matched and
107
+ * scanned as if it were this session's context).
108
+ *
109
+ * A walker calls this with both bare names and root-relative paths, so it must
110
+ * answer for either; a bare name carries no `.claude` context and is judged only
111
+ * against `node_modules`.
112
+ * @param {string} entry a bare entry name or a path relative to the scan root
113
+ * @returns {boolean}
114
+ */
115
+ export function excludeFromContextScan(entry) {
116
+ if (excludeNodeModules(entry)) return true;
117
+ const parts = entry.split(/[/\\]/);
118
+ const claudeIndex = parts.indexOf(".claude");
119
+ const tail = parts.slice(claudeIndex + 1);
120
+ if (claudeIndex === -1 || tail.length === 0) return false;
121
+ // `.claude/<file>.md` is context (a top-level note); anything else directly
122
+ // under `.claude` must be a whitelisted subdirectory to be walked at all.
123
+ if (tail.length === 1 && tail[0].endsWith(".md")) return false;
124
+ return !CLAUDE_CONTEXT_SUBDIRS.includes(tail[0]);
125
+ }
package/src/html.mjs CHANGED
@@ -2313,26 +2313,45 @@ function multiUrlAttr(value) {
2313
2313
  * `context` selects the per-URL check the caller applies: resource URLs get the
2314
2314
  * exfil-shape test; form-submission and meta-refresh targets additionally flag
2315
2315
  * any absolute off-origin destination.
2316
+ *
2317
+ * `autoFetched` says whether reaching this URL takes a deliberate act. Exactly
2318
+ * one attribute here does: `href` on an `<a>`, which somebody has to follow.
2319
+ * Every other one is fetched by the renderer on sight (`src`, `srcset`,
2320
+ * `background`, and `href` on a `<link>`), fires on a click aimed at something
2321
+ * else (`ping`), or navigates on its own (a form action, a meta refresh). The
2322
+ * distinction is the caller's severity line, not a detection line — the URL is
2323
+ * reported either way (see the exfil tier in ../src/output.mjs).
2316
2324
  * @param {string} text
2317
- * @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
2325
+ * @returns {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>}
2318
2326
  */
2319
2327
  function extractHtmlUrls(text) {
2320
2328
  const tree = parseFragment(text);
2321
- /** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
2329
+ /** @type {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>} */
2322
2330
  const urls = [];
2323
2331
  visit(tree, "element", (/** @type {any} */ node) => {
2324
2332
  // hast element nodes always carry a `properties` object (parse5 sets it).
2325
2333
  const props = node.properties;
2326
2334
  const isImage = node.tagName === "img";
2335
+ const isAnchor = node.tagName === "a";
2327
2336
  for (const key of ["src", "href", "background"])
2328
2337
  if (typeof props[key] === "string")
2329
- urls.push({ url: props[key], isImage, context: "resource" });
2338
+ urls.push({
2339
+ url: props[key],
2340
+ isImage,
2341
+ autoFetched: !(key === "href" && isAnchor),
2342
+ context: "resource",
2343
+ });
2330
2344
  for (const key of ["srcSet", "ping"])
2331
2345
  for (const url of multiUrlAttr(props[key]))
2332
- urls.push({ url, isImage, context: "resource" });
2346
+ urls.push({ url, isImage, autoFetched: true, context: "resource" });
2333
2347
  for (const key of ["action", "formAction"])
2334
2348
  if (typeof props[key] === "string")
2335
- urls.push({ url: props[key], isImage: false, context: "form" });
2349
+ urls.push({
2350
+ url: props[key],
2351
+ isImage: false,
2352
+ autoFetched: true,
2353
+ context: "form",
2354
+ });
2336
2355
  // rehype delivers `http-equiv` as an array (comma-separated); join it back
2337
2356
  // so a `refresh` directive is matched regardless of how it was tokenized.
2338
2357
  const httpEquiv = Array.isArray(props.httpEquiv)
@@ -2344,7 +2363,13 @@ function extractHtmlUrls(text) {
2344
2363
  typeof props.content === "string"
2345
2364
  ) {
2346
2365
  const url = metaRefreshUrl(props.content);
2347
- if (url) urls.push({ url, isImage: false, context: "refresh" });
2366
+ if (url)
2367
+ urls.push({
2368
+ url,
2369
+ isImage: false,
2370
+ autoFetched: true,
2371
+ context: "refresh",
2372
+ });
2348
2373
  }
2349
2374
  });
2350
2375
  return urls;
@@ -2362,13 +2387,18 @@ const OFF_ORIGIN_REASON = {
2362
2387
  * and HTML attributes (src/href/background/srcset/ping, form action/formaction,
2363
2388
  * meta-refresh). Detection only — the text is never modified; the caller
2364
2389
  * surfaces the threats as a warning.
2390
+ *
2391
+ * `autoFetched` marks a threat that needs no deliberate act to fire — a
2392
+ * rendered image, a stylesheet, a form target, a meta refresh — as opposed to a
2393
+ * link somebody has to follow. Both are reported; the caller uses it to decide
2394
+ * how loudly (see the exfil tier in ./output.mjs).
2365
2395
  * @param {string} text
2366
- * @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
2396
+ * @returns {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }> | null}
2367
2397
  */
2368
2398
  export function detectExfil(text) {
2369
2399
  if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
2370
2400
 
2371
- /** @type {Array<{ isImage: boolean, reason: string, target: string }>} */
2401
+ /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string }>} */
2372
2402
  const threats = [];
2373
2403
 
2374
2404
  try {
@@ -2386,20 +2416,25 @@ export function detectExfil(text) {
2386
2416
  if (!reason) return;
2387
2417
  threats.push({
2388
2418
  isImage: node.type === "image",
2419
+ // A markdown image is fetched the moment the document renders; a link
2420
+ // (or a definition, which only names one) is not.
2421
+ autoFetched: node.type === "image",
2389
2422
  reason,
2390
2423
  target: urlHost(node.url),
2391
2424
  });
2392
2425
  });
2393
2426
 
2394
2427
  // HTML attributes (not AST nodes in remark).
2395
- for (const { url, isImage, context } of extractHtmlUrls(text)) {
2428
+ for (const { url, isImage, autoFetched, context } of extractHtmlUrls(
2429
+ text,
2430
+ )) {
2396
2431
  const reason =
2397
2432
  checkExfilUrl(url) ||
2398
2433
  (context !== "resource" && isOffOrigin(url)
2399
2434
  ? OFF_ORIGIN_REASON[context]
2400
2435
  : null);
2401
2436
  if (!reason) continue;
2402
- threats.push({ isImage, reason, target: urlHost(url) });
2437
+ threats.push({ isImage, autoFetched, reason, target: urlHost(url) });
2403
2438
  }
2404
2439
  } catch {
2405
2440
  // The parse/visit blew up (stack overflow on pathological nesting). Fail
@@ -2409,6 +2444,9 @@ export function detectExfil(text) {
2409
2444
  return [
2410
2445
  {
2411
2446
  isImage: false,
2447
+ // Fail closed on the severity tier too: an input the scanner could not
2448
+ // read is not evidence that what it hides is harmless.
2449
+ autoFetched: true,
2412
2450
  reason: "input too deeply nested to scan for exfil URLs",
2413
2451
  target: "(unparseable HTML)",
2414
2452
  },
package/src/index.mjs CHANGED
@@ -73,7 +73,8 @@ export {
73
73
  * its own removal.
74
74
  *
75
75
  * `found` names the categories neutralized; `warnings` carries the
76
- * operator-facing notices. `cleaned` is always a string, and a change only
76
+ * operator-facing notices and `notes` the quiet tier (see `./severity.mjs`).
77
+ * `cleaned` is always a string, and a change only
77
78
  * ever carries a warning (no silent suppression). `options` is optional and
78
79
  * tolerates an explicit `null`/`undefined` (treated the same as omitted) —
79
80
  * only a genuinely malformed `text` (not a string) throws, deliberately: a
@@ -83,7 +84,7 @@ export {
83
84
  *
84
85
  * The layer bodies live in `./output.mjs`; this is a facade over them, not a
85
86
  * second implementation (see the module doc). It narrows `sanitizeText`'s result
86
- * to the three fields this entry has always promised — `modified`/`sgrNote`
87
+ * to the four fields this entry promises — `modified`/`sgrNote`
87
88
  * describe the tool-output pipeline's banner, and `reveal` is produced only by
88
89
  * options this facade does not expose. `html` selects Layers 2 AND 3 together
89
90
  * here, which is the surface this entry has always had; `sanitizeText` takes
@@ -91,15 +92,15 @@ export {
91
92
  * detection without Layer 2's splice.
92
93
  * @param {string} text
93
94
  * @param {{ html?: boolean } | null} [options]
94
- * @returns {Promise<{ cleaned: string, found: string[], warnings: string[] }>}
95
+ * @returns {Promise<{ cleaned: string, found: string[], warnings: string[], notes: string[] }>}
95
96
  */
96
97
  export async function sanitize(text, options) {
97
98
  if (typeof text !== "string")
98
99
  throw new TypeError("sanitize(text, options): text must be a string");
99
100
  const { html = false } = options ?? {};
100
- const { cleaned, found, warnings } = await sanitizeText(text, {
101
+ const { cleaned, found, warnings, notes } = await sanitizeText(text, {
101
102
  html,
102
103
  exfilScan: html,
103
104
  });
104
- return { cleaned, found, warnings };
105
+ return { cleaned, found, warnings, notes };
105
106
  }
@@ -12,6 +12,10 @@
12
12
  * The target file set is CALLER-SUPPLIED: pass the globs your agent's
13
13
  * instruction files live under (e.g. `["CLAUDE.md", "AGENTS.md",
14
14
  * ".claude/**\/*.md", "**\/SKILL.md"]`), so no agent's convention is baked in.
15
+ * Claude Code's own convention is re-exported below
16
+ * ({@link CLAUDE_INSTRUCTION_GLOBS} / {@link excludeFromContextScan}) so a
17
+ * caller that wants it takes the SessionStart hook's exact scope rather than
18
+ * approximating it — see ./claude-context.mjs.
15
19
  */
16
20
  import {
17
21
  readFileSync,
@@ -36,6 +40,18 @@ import {
36
40
  countPayloadInvisible,
37
41
  stripInvisible,
38
42
  } from "./invisible.mjs";
43
+ import { excludeNodeModules } from "./claude-context.mjs";
44
+
45
+ // The library's public door onto Claude Code's context scope. The definitions
46
+ // live in a dependency-free data module because the SessionStart hook imports
47
+ // them relatively (see ./claude-context.mjs); re-exporting them here is what
48
+ // makes `agent-sanitizer/instructions` the single place a CLI, a port or a fork
49
+ // reads that scope from instead of re-spelling it.
50
+ export {
51
+ CLAUDE_CONTEXT_SUBDIRS,
52
+ CLAUDE_INSTRUCTION_GLOBS,
53
+ excludeFromContextScan,
54
+ } from "./claude-context.mjs";
39
55
 
40
56
  // Prefix on any decoded tag-character payload. The decoded text is
41
57
  // attacker-controlled and flows into the scan report, which itself reaches model
@@ -296,18 +312,28 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
296
312
  * cannot be resolved (a dangling symlink or unreadable entry inside the
297
313
  * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
298
314
  * project.
315
+ *
316
+ * `exclude` prunes the WALK, which is where a wide glob's cost actually is —
317
+ * a pattern that merely fails to match a bulk directory still pays to read it.
318
+ * It is composed with, never replaces, the unconditional `node_modules` prune:
319
+ * a caller narrowing the scan must not be able to widen it into a dependency
320
+ * tree. Pass {@link excludeFromContextScan} to take Claude Code's own scope.
299
321
  * @param {string[]} globs
300
- * @param {{ cwd?: string }} [options]
322
+ * @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
301
323
  * @returns {string[]}
302
324
  */
303
- export function findInstructionFiles(globs, { cwd = process.cwd() } = {}) {
325
+ export function findInstructionFiles(
326
+ globs,
327
+ { cwd = process.cwd(), exclude } = {},
328
+ ) {
304
329
  const literalRoot = resolve(cwd);
305
330
  const realRoot = realpathSync(literalRoot);
306
331
  const seen = new Set();
307
332
  for (const pattern of globs)
308
333
  for (const name of globSync(pattern, {
309
334
  cwd,
310
- exclude: (entry) => entry === "node_modules",
335
+ exclude: (entry) =>
336
+ excludeNodeModules(entry) || (exclude?.(entry) ?? false),
311
337
  })) {
312
338
  // globSync returns absolute paths verbatim for an absolute pattern and
313
339
  // cwd-relative names otherwise; joining an already-absolute name would
@@ -324,12 +350,16 @@ export function findInstructionFiles(globs, { cwd = process.cwd() } = {}) {
324
350
  * findings, each path reported relative to `cwd`. Unreadable/missing files are
325
351
  * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
326
352
  * @param {string[]} globs
327
- * @param {{ cwd?: string }} [options]
353
+ * @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
354
+ * `exclude` is forwarded to {@link findInstructionFiles}
328
355
  * @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
329
356
  */
330
- export function scanInstructionFiles(globs, { cwd = process.cwd() } = {}) {
357
+ export function scanInstructionFiles(
358
+ globs,
359
+ { cwd = process.cwd(), exclude } = {},
360
+ ) {
331
361
  const out = [];
332
- for (const file of findInstructionFiles(globs, { cwd })) {
362
+ for (const file of findInstructionFiles(globs, { cwd, exclude })) {
333
363
  let content;
334
364
  try {
335
365
  content = readFileSync(file, "utf-8");
package/src/invisible.mjs CHANGED
@@ -175,11 +175,7 @@ export const LONG_RUN_RE = new RegExp(
175
175
  */
176
176
  export function describeStripped(invisFound, deAnsi) {
177
177
  let msg = `Stripped: ${invisFound.map((code) => CATEGORY_LABELS[code]).join(", ")}`;
178
- LONG_RUN_RE.lastIndex = 0;
179
- // Probe only the PAYLOAD invisibles: a legitimate emoji/flag/variation
180
- // sequence is carve-out-preserved and masked out here, so it never trips the
181
- // injection marker (alert fatigue) while a genuine hidden run still surfaces.
182
- if (LONG_RUN_RE.test(payloadInvisibleView(deAnsi)))
178
+ if (payloadLongRunSample(deAnsi) !== null)
183
179
  msg += " [LONG RUN — possible injection payload]";
184
180
  return (
185
181
  msg +
@@ -1048,6 +1044,90 @@ export function payloadInvisibleView(text) {
1048
1044
  return out;
1049
1045
  }
1050
1046
 
1047
+ /**
1048
+ * The first payload-invisible LONG RUN in `text`, or null when there is none.
1049
+ *
1050
+ * THE definition of "this text carries a hidden run", shared by every consumer
1051
+ * that has an opinion about one: the strip's `[LONG RUN — possible injection
1052
+ * payload]` marker, the prompt gate's block decision, and the tool-output
1053
+ * severity tier. They used to spell it twice, and differently — the marker
1054
+ * probed the PAYLOAD view while the prompt gate probed the raw text, so a
1055
+ * legitimate ten-emoji flag sequence (carve-out-preserved, never stripped) was
1056
+ * quietly enough to BLOCK a prompt while the strip that saw the same text
1057
+ * declined to even flag it. Masking the preserved invisibles is the right half
1058
+ * of that disagreement: a run the carve-out keeps is rendering work, not a
1059
+ * channel, and the joiners it does NOT keep are counted as payload anyway (see
1060
+ * {@link countEffectiveInvisible}).
1061
+ *
1062
+ * Because the view replaces only PRESERVED invisibles (and visible characters)
1063
+ * with spaces, a match consists solely of payload code points and is therefore
1064
+ * byte-identical to the corresponding span of `text` — so a caller may report
1065
+ * the sample verbatim.
1066
+ * @param {string} text
1067
+ * @returns {string | null}
1068
+ */
1069
+ export function payloadLongRunSample(text) {
1070
+ LONG_RUN_RE.lastIndex = 0;
1071
+ return payloadInvisibleView(text).match(LONG_RUN_RE)?.[0] ?? null;
1072
+ }
1073
+
1074
+ /**
1075
+ * How many invisible code points in `text` the strip layer treats as PAYLOAD:
1076
+ * the ones {@link countPayloadInvisible} counts, plus the joiners that sit in a
1077
+ * genuine linguistic context but exceed the carve-out's preservation budget.
1078
+ *
1079
+ * The surplus term closes the preserved-joiner covert channel (O3):
1080
+ * `countPayloadInvisible` excludes every ZWNJ/ZWJ doing real rendering work, so
1081
+ * an attacker who alternates `letter joiner letter joiner …` — every joiner
1082
+ * legitimately between two cursive letters — counts as ZERO there. The strip
1083
+ * layer already refuses that (it preserves joiners only up to
1084
+ * TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP and strips the rest),
1085
+ * so the surplus is read back OFF the strip — the SSOT — rather than by
1086
+ * re-deriving the budget here, which is what would drift.
1087
+ *
1088
+ * A leading BOM is preserved by the strip but counted by
1089
+ * {@link countPayloadInvisible}, so the difference can go slightly negative;
1090
+ * hence the clamp.
1091
+ * @param {string} text ANSI-stripped text (an escape sequence can hide invisibles)
1092
+ * @returns {number}
1093
+ */
1094
+ export function countEffectiveInvisible(text) {
1095
+ const payload = countPayloadInvisible(text);
1096
+ const surplusPreservedJoiners = Math.max(
1097
+ 0,
1098
+ [...text].length - [...stripInvisible(text)].length - payload,
1099
+ );
1100
+ return payload + surplusPreservedJoiners;
1101
+ }
1102
+
1103
+ /**
1104
+ * True when the invisible characters in `text` are INCIDENTAL: no hidden run,
1105
+ * and too few of them in total to carry an instruction.
1106
+ *
1107
+ * This is a severity line, not a strip line — the bytes are removed either way
1108
+ * (see ../src/severity.mjs). It exists because a single soft hyphen in a
1109
+ * pasted paragraph, or one variation selector a font demanded, raised the exact
1110
+ * `WARNING: Tool output sanitized` an encoded payload does, and a warning that
1111
+ * fires on a stray character in ordinary prose is one operators learn to skip.
1112
+ *
1113
+ * The bar is {@link LONG_RUN_THRESHOLD} — the count this module already calls
1114
+ * "payload length" — applied to the WHOLE text rather than to one run, so it is
1115
+ * strictly stronger than the run probe: fewer than ten payload-invisible code
1116
+ * points, however they are distributed, cannot spell a smuggled instruction (ten
1117
+ * tag characters are ten ASCII letters). Deliberately NOT the far looser
1118
+ * {@link SCATTERED_THRESHOLD} of 30, which is the prompt gate's BLOCK bar: 29
1119
+ * tag characters is a short sentence, and staying quiet about a short sentence
1120
+ * hidden in a tool result is not a trade worth making.
1121
+ * @param {string} text ANSI-stripped text, invisible runs intact
1122
+ * @returns {boolean}
1123
+ */
1124
+ export function isIncidentalInvisible(text) {
1125
+ return (
1126
+ payloadLongRunSample(text) === null &&
1127
+ countEffectiveInvisible(text) < LONG_RUN_THRESHOLD
1128
+ );
1129
+ }
1130
+
1051
1131
  /**
1052
1132
  * Strip payload-capable invisible chars and report which categories were
1053
1133
  * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
package/src/layer1.mjs CHANGED
@@ -109,6 +109,21 @@ export function stripAnsiFully(input, kinds) {
109
109
  return out;
110
110
  }
111
111
 
112
+ /**
113
+ * What a reader is told when the ONLY thing a strip removed was inert ANSI (see
114
+ * {@link isBenignAnsiKinds}).
115
+ *
116
+ * It lives here, beside the predicate that decides it, because every entry point
117
+ * that can reach that verdict must say the same thing: the tool-output pipeline,
118
+ * the prompt gate's pass-with-note, and any host wiring its own. The wording is
119
+ * deliberately not `describeStripped`'s — "Stripped: ANSI escapes" names a
120
+ * category that reads like an attack, when the honest report is "these were
121
+ * colour codes, and here is how to look at the raw bytes".
122
+ */
123
+ export const INERT_ANSI_NOTE =
124
+ "Inert ANSI stripped (display-only colour and/or a stray escape byte that " +
125
+ "formed no control sequence); pipe through cat -v to inspect raw escapes.";
126
+
112
127
  /**
113
128
  * True when the ANSI a Layer-1 strip removed was INERT: every removed sequence
114
129
  * was either a display-only SGR colour token or a LONE 7-bit `ESC` that opened