agent-sanitizer 2.47.14 → 2.48.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/THREAT-MODEL.md CHANGED
@@ -383,6 +383,22 @@ build that emits that event, and `scan-loaded-instructions` switched off in
383
383
  Nothing on disk tells them apart, so the PreToolUse gate names all three, once
384
384
  per session, rather than leaving the gap silent.
385
385
 
386
+ A fourth state is not one of them and is not reported as one: the host emits no
387
+ event when it has nothing to announce. The gate therefore asks not "does anything
388
+ load at launch" but "does anything load whose load `InstructionsLoaded` NAMES,
389
+ with bytes in it" — the `eventNamed` column of the same table, read through
390
+ `announcedByInstructionsLoaded` for the project's own tree and through
391
+ `USER_GLOBAL_EVENT_NAMED_GLOBS` for the user-global root, whose files carry no
392
+ `.claude` segment to classify by. A launch holding only an `AGENTS.md`, a skill
393
+ or an empty `~/.claude/CLAUDE.md` fires no event however the hook is wired, so
394
+ the missing scan there is evidence of nothing. The trade this accepts is a false
395
+ NEGATIVE: a repo whose announced instruction files all sit in subdirectories —
396
+ a monorepo of per-package `CLAUDE.md` under a root carrying only `AGENTS.md`, on
397
+ a machine with no user-global memory — loses the notice until a tool call touches
398
+ one of those directories, which is what the gate's per-call `touchedDir` check
399
+ recovers. Weighed against a false POSITIVE on every session launched outside a
400
+ project, which trains the operator to skip the notice everywhere.
401
+
386
402
  That table is a claim about someone else's product, so the event that names a
387
403
  loaded file is also what falsifies it. `contextScopeContradiction` checks every
388
404
  path the hook is handed and reports two observations: context loading out of a
@@ -38,9 +38,10 @@ import {
38
38
  // module's, not a copy of the SessionStart hook's target-discovery glue.
39
39
  import {
40
40
  ancestorInstructionFiles,
41
- CLAUDE_CONTEXT_SUBDIRS,
41
+ announcedByInstructionsLoaded,
42
42
  CLAUDE_LAUNCH_GLOBS,
43
43
  excludeFromContextScan,
44
+ USER_GLOBAL_EVENT_NAMED_GLOBS,
44
45
  } from "../../src/claude-context.mjs";
45
46
 
46
47
  // Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
@@ -370,11 +371,10 @@ export function launchInstructionFiles(dir) {
370
371
  }
371
372
 
372
373
  /**
373
- * Whether anything Claude Code loads at launch from `dir` actually has bytes.
374
- * An existing but EMPTY file (a freshly `touch`ed `~/.claude/CLAUDE.md`) is
375
- * nothing to load: Claude Code 2.1.246 fires no InstructionsLoaded event when
376
- * a launch has nothing in it, so the missing event is expected there, not a
377
- * sign the scanner is unwired.
374
+ * Whether any of `files` has bytes for the host to load. An existing but EMPTY
375
+ * file (a freshly `touch`ed `~/.claude/CLAUDE.md`) is nothing to load: Claude
376
+ * Code 2.1.246 fires no InstructionsLoaded event when a launch has nothing in
377
+ * it, so the missing event is expected there, not a sign the scanner is unwired.
378
378
  *
379
379
  * A file this uid cannot even STAT (EACCES, EISDIR, ELOOP…) is unvetted
380
380
  * context, not evidence of absence — the same split scan-invisible-chars.mjs's
@@ -382,11 +382,11 @@ export function launchInstructionFiles(dir) {
382
382
  * as "no content" would suppress the notice over a file that may carry a real,
383
383
  * unscanned payload, so only ENOENT counts as nothing there; anything else
384
384
  * counts as content and leaves the notice free to fire.
385
- * @param {string} dir
385
+ * @param {string[]} files
386
386
  * @returns {boolean}
387
387
  */
388
- function launchHasContent(dir) {
389
- for (const file of launchInstructionFiles(dir)) {
388
+ function anyFileHasBytes(files) {
389
+ for (const file of files) {
390
390
  try {
391
391
  if (statSync(file).size > 0) return true;
392
392
  } catch (err) {
@@ -397,13 +397,32 @@ function launchHasContent(dir) {
397
397
  return false;
398
398
  }
399
399
 
400
+ /**
401
+ * The files loading at launch from `dir` whose load `InstructionsLoaded` would
402
+ * ANNOUNCE. Claude Code names CLAUDE.md, CLAUDE.local.md and `.claude/rules` as
403
+ * it loads them and stays silent for every other kind, so a launch carrying only
404
+ * an `AGENTS.md` or a skill fires no event however the hook is wired — and its
405
+ * silence is evidence of nothing.
406
+ * @param {string} dir
407
+ * @returns {string[]}
408
+ */
409
+ function announcedLaunchFiles(dir) {
410
+ return launchInstructionFiles(dir).filter(announcedByInstructionsLoaded);
411
+ }
412
+
400
413
  /**
401
414
  * Whether the USER-GLOBAL `~/.claude` memory and rules — a second root Claude
402
415
  * Code loads at launch regardless of the project directory (see
403
- * scan-loaded-instructions.mjs's header) — has any bytes. `launchHasContent`
404
- * cannot see this root on its own: it is `dir`-relative, and `~/.claude` is
405
- * outside `dir`'s own tree whenever the project is not `$HOME` itself, exactly
406
- * the case a project opened anywhere but home is in.
416
+ * scan-loaded-instructions.mjs's header) — holds an announced file with bytes.
417
+ * {@link announcedLaunchFiles} cannot see this root on its own: it is
418
+ * `dir`-relative, and `~/.claude` is outside `dir`'s own tree whenever the
419
+ * project is not `$HOME` itself, exactly the case a project opened anywhere but
420
+ * home is in.
421
+ *
422
+ * Globs the announced kinds directly rather than filtering paths afterwards:
423
+ * this root IS a `.claude` directory, and under `CLAUDE_CONFIG_DIR` it may carry
424
+ * no `.claude` segment at all, so a path-shape classifier has nothing to read
425
+ * there and would answer "not announced" for every user-global rule.
407
426
  *
408
427
  * Honours `CLAUDE_CONFIG_DIR` the way plugin/scripts/enable-auto-update.mjs's
409
428
  * own resolution does, since that root — not always `~/.claude` — is where
@@ -413,19 +432,12 @@ function launchHasContent(dir) {
413
432
  */
414
433
  function userGlobalLaunchHasContent(env = process.env) {
415
434
  const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
416
- const candidates = globSync(
417
- ["*.md", ...CLAUDE_CONTEXT_SUBDIRS.map((sub) => `${sub}/**/*.md`)],
418
- { cwd: configDir, exclude: excludeFromContextScan },
419
- ).map((name) => join(configDir, name));
420
- for (const file of candidates) {
421
- try {
422
- if (statSync(file).size > 0) return true;
423
- } catch (err) {
424
- if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
425
- return true;
426
- }
427
- }
428
- return false;
435
+ return anyFileHasBytes(
436
+ globSync([...USER_GLOBAL_EVENT_NAMED_GLOBS], {
437
+ cwd: configDir,
438
+ exclude: excludeFromContextScan,
439
+ }).map((name) => join(configDir, name)),
440
+ );
429
441
  }
430
442
 
431
443
  /**
@@ -440,8 +452,9 @@ function userGlobalLaunchHasContent(env = process.env) {
440
452
  * tool touched a directory that DOES carry real, unscanned content.
441
453
  *
442
454
  * SessionStart scans what loads at launch; a subdirectory's file is scanned by
443
- * the event; no scan means nothing says so — unless nothing touched so far had
444
- * anything to scan either. The notice names all three remaining causes, since
455
+ * the event; no scan means nothing says so — unless nothing touched so far held
456
+ * a file the event would have ANNOUNCED, with bytes in it. The three remaining
457
+ * causes are named together, since
445
458
  * the marker cannot tell them apart: an unwired event, a Claude Code older than
446
459
  * EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
447
460
  * @param {string} [sessionId] the harness's session identity (see
@@ -461,11 +474,14 @@ export function instructionsLoadedGapNotice(
461
474
  if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
462
475
  const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
463
476
  const launchHasBytes =
464
- !launchCached && (launchHasContent(dir) || userGlobalLaunchHasContent());
477
+ !launchCached &&
478
+ (anyFileHasBytes(announcedLaunchFiles(dir)) ||
479
+ userGlobalLaunchHasContent());
465
480
  if (!launchCached && !launchHasBytes)
466
481
  writeSentinelFile(launchEmptyFile(sessionId));
467
482
  const touchedHasBytes =
468
- touchedDir !== undefined && launchHasContent(touchedDir);
483
+ touchedDir !== undefined &&
484
+ anyFileHasBytes(announcedLaunchFiles(touchedDir));
469
485
  if (!launchHasBytes && !touchedHasBytes) return null;
470
486
  return (
471
487
  "agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.47.14",
3
+ "version": "2.48.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": {
@@ -301,6 +301,46 @@ function classifyContextPath(path) {
301
301
  );
302
302
  }
303
303
 
304
+ /**
305
+ * The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
306
+ * `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
307
+ * whatever the project is.
308
+ *
309
+ * A second spelling because that root is a `.claude` directory ITSELF: its files
310
+ * are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
311
+ * `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
312
+ * {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
313
+ * nothing there to classify by. Every row's shape is asserted in
314
+ * test/claude-context.test.mjs, since a shape with no spelling here would glob
315
+ * to nothing and read as "no event is coming".
316
+ */
317
+ export const USER_GLOBAL_EVENT_NAMED_GLOBS = Object.freeze(
318
+ CLAUDE_CONTEXT_KINDS.filter((row) => row.eventNamed).map((row) =>
319
+ row.shape === "dir-file" ? row.name : `${row.name}/**/*.md`,
320
+ ),
321
+ );
322
+
323
+ /**
324
+ * Whether Claude Code announces loading `path` with an `InstructionsLoaded`
325
+ * event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
326
+ * path.
327
+ *
328
+ * The complement of {@link contextScopeContradiction}, which checks the same
329
+ * column against an event that DID fire. This one answers before any fires, so a
330
+ * consumer can tell "the event is not coming" from "the event never came": a
331
+ * launch carrying only an `AGENTS.md` or a skill has nothing for the host to
332
+ * announce, and its silence is evidence of nothing.
333
+ *
334
+ * A path the table does not name gets `false`, the same conservative answer a
335
+ * row added without the flag gets: this says an event IS coming, never that a
336
+ * file is uninteresting.
337
+ * @param {string} path absolute or relative; only its segments are read
338
+ * @returns {boolean}
339
+ */
340
+ export function announcedByInstructionsLoaded(path) {
341
+ return classifyContextPath(path)?.eventNamed === true;
342
+ }
343
+
304
344
  // The `load_reason` values that mean Claude Code reached the file on its own —
305
345
  // its launch scan, and its walk into a directory. Every other reason names a
306
346
  // file something else chose, which is not evidence about the scan's scope.
@@ -51,6 +51,7 @@ import { excludeNodeModules } from "./claude-context.mjs";
51
51
  // reads that scope from instead of re-spelling it.
52
52
  export {
53
53
  ancestorInstructionFiles,
54
+ announcedByInstructionsLoaded,
54
55
  CLAUDE_CONTEXT_KINDS,
55
56
  CLAUDE_CONTEXT_SUBDIRS,
56
57
  CLAUDE_DIR_INSTRUCTION_FILES,
@@ -59,6 +60,7 @@ export {
59
60
  CLAUDE_MEMORY_FILES,
60
61
  contextScopeContradiction,
61
62
  excludeFromContextScan,
63
+ USER_GLOBAL_EVENT_NAMED_GLOBS,
62
64
  } from "./claude-context.mjs";
63
65
 
64
66
  // Prefix on any decoded tag-character payload. The decoded text is
@@ -56,6 +56,24 @@ export function excludeNodeModules(entry: string): boolean;
56
56
  * @returns {boolean}
57
57
  */
58
58
  export function excludeFromContextScan(entry: string): boolean;
59
+ /**
60
+ * Whether Claude Code announces loading `path` with an `InstructionsLoaded`
61
+ * event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
62
+ * path.
63
+ *
64
+ * The complement of {@link contextScopeContradiction}, which checks the same
65
+ * column against an event that DID fire. This one answers before any fires, so a
66
+ * consumer can tell "the event is not coming" from "the event never came": a
67
+ * launch carrying only an `AGENTS.md` or a skill has nothing for the host to
68
+ * announce, and its silence is evidence of nothing.
69
+ *
70
+ * A path the table does not name gets `false`, the same conservative answer a
71
+ * row added without the flag gets: this says an event IS coming, never that a
72
+ * file is uninteresting.
73
+ * @param {string} path absolute or relative; only its segments are read
74
+ * @returns {boolean}
75
+ */
76
+ export function announcedByInstructionsLoaded(path: string): boolean;
59
77
  /**
60
78
  * What a file the host just loaded as model context says about this table, or
61
79
  * null when it says nothing new. The InstructionsLoaded event is the only
@@ -156,3 +174,17 @@ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
156
174
  * set, and with {@link excludeFromContextScan} to prune the `.claude` walk.
157
175
  */
158
176
  export const CLAUDE_LAUNCH_GLOBS: readonly string[];
177
+ /**
178
+ * The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
179
+ * `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
180
+ * whatever the project is.
181
+ *
182
+ * A second spelling because that root is a `.claude` directory ITSELF: its files
183
+ * are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
184
+ * `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
185
+ * {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
186
+ * nothing there to classify by. Every row's shape is asserted in
187
+ * test/claude-context.test.mjs, since a shape with no spelling here would glob
188
+ * to nothing and read as "no event is coming".
189
+ */
190
+ export const USER_GLOBAL_EVENT_NAMED_GLOBS: readonly string[];
@@ -109,8 +109,9 @@ export function launchInstructionFiles(dir: string): string[];
109
109
  * tool touched a directory that DOES carry real, unscanned content.
110
110
  *
111
111
  * SessionStart scans what loads at launch; a subdirectory's file is scanned by
112
- * the event; no scan means nothing says so — unless nothing touched so far had
113
- * anything to scan either. The notice names all three remaining causes, since
112
+ * the event; no scan means nothing says so — unless nothing touched so far held
113
+ * a file the event would have ANNOUNCED, with bytes in it. The three remaining
114
+ * causes are named together, since
114
115
  * the marker cannot tell them apart: an unwired event, a Claude Code older than
115
116
  * EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
116
117
  * @param {string} [sessionId] the harness's session identity (see
@@ -148,4 +148,4 @@ export function atomicReplaceFile(absPath: string, data: string, mode: number, t
148
148
  * @returns {boolean}
149
149
  */
150
150
  export function cleanFile(absPath: string, lstat?: (path: string) => import("node:fs").Stats): boolean;
151
- export { ancestorInstructionFiles, CLAUDE_CONTEXT_KINDS, CLAUDE_CONTEXT_SUBDIRS, CLAUDE_DIR_INSTRUCTION_FILES, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, CLAUDE_MEMORY_FILES, contextScopeContradiction, excludeFromContextScan } from "./claude-context.mjs";
151
+ 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";
@@ -56,6 +56,24 @@ export function excludeNodeModules(entry: string): boolean;
56
56
  * @returns {boolean}
57
57
  */
58
58
  export function excludeFromContextScan(entry: string): boolean;
59
+ /**
60
+ * Whether Claude Code announces loading `path` with an `InstructionsLoaded`
61
+ * event — the `eventNamed` column of {@link CLAUDE_CONTEXT_KINDS}, asked of one
62
+ * path.
63
+ *
64
+ * The complement of {@link contextScopeContradiction}, which checks the same
65
+ * column against an event that DID fire. This one answers before any fires, so a
66
+ * consumer can tell "the event is not coming" from "the event never came": a
67
+ * launch carrying only an `AGENTS.md` or a skill has nothing for the host to
68
+ * announce, and its silence is evidence of nothing.
69
+ *
70
+ * A path the table does not name gets `false`, the same conservative answer a
71
+ * row added without the flag gets: this says an event IS coming, never that a
72
+ * file is uninteresting.
73
+ * @param {string} path absolute or relative; only its segments are read
74
+ * @returns {boolean}
75
+ */
76
+ export function announcedByInstructionsLoaded(path: string): boolean;
59
77
  /**
60
78
  * What a file the host just loaded as model context says about this table, or
61
79
  * null when it says nothing new. The InstructionsLoaded event is the only
@@ -156,3 +174,17 @@ export const CLAUDE_INSTRUCTION_GLOBS: readonly string[];
156
174
  * set, and with {@link excludeFromContextScan} to prune the `.claude` walk.
157
175
  */
158
176
  export const CLAUDE_LAUNCH_GLOBS: readonly string[];
177
+ /**
178
+ * The `eventNamed` kinds spelled relative to the USER-GLOBAL config root — the
179
+ * `~/.claude` (or `CLAUDE_CONFIG_DIR`) directory Claude Code loads at launch
180
+ * whatever the project is.
181
+ *
182
+ * A second spelling because that root is a `.claude` directory ITSELF: its files
183
+ * are `CLAUDE.md` and `rules/x.md`, not `.claude/rules/x.md`, and under
184
+ * `CLAUDE_CONFIG_DIR` the path may carry no `.claude` segment at all — so
185
+ * {@link announcedByInstructionsLoaded}, which reads a path's own segments, has
186
+ * nothing there to classify by. Every row's shape is asserted in
187
+ * test/claude-context.test.mjs, since a shape with no spelling here would glob
188
+ * to nothing and read as "no event is coming".
189
+ */
190
+ export const USER_GLOBAL_EVENT_NAMED_GLOBS: readonly string[];