agent-sanitizer 2.66.4 → 2.66.5

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
@@ -250,7 +250,7 @@ To wire them yourself instead, one entry dispatches every mode on `--hook=`:
250
250
  | `SessionStart` | `scan-invisible-chars` |
251
251
  | `InstructionsLoaded` | `scan-loaded-instructions` |
252
252
 
253
- **Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing, and the one-time PreToolUse coverage notice is the only thing that says so.
253
+ **Wire all five.** The instruction-file scan is split across two of them: `SessionStart` covers the files that load at launch, and `InstructionsLoaded` covers every one a subdirectory loads later. A host that wires the first without the second leaves a nested `CLAUDE.md` scanned by nothing. The one-time PreToolUse coverage notice says so, but only when a file announced at launch went unscanned: a project whose only `CLAUDE.md` files sit in subdirectories gets no notice.
254
254
 
255
255
  `InstructionsLoaded` needs Claude Code **2.1.69 or newer** — 2.1.68 does not emit the event, so the hook never fires however you wire it. Check with `claude --version`.
256
256
 
package/THREAT-MODEL.md CHANGED
@@ -547,8 +547,10 @@ entirely: a host that never wired the `InstructionsLoaded` event to
547
547
  `scan-loaded-instructions`, a Claude Code older than 2.1.69, which is the first
548
548
  build that emits that event, and `scan-loaded-instructions` switched off in
549
549
  `AGENT_SANITIZER_DISABLED_HOOKS`.
550
- Nothing on disk tells them apart, so the PreToolUse gate names all three, once
551
- per session, rather than leaving the gap silent.
550
+ The PreToolUse gate reports the gap once per session rather than leaving it
551
+ silent. It checks the disable list and asks the session's own `claude --version`,
552
+ so the notice names only the cause it could not rule out: usually the wiring,
553
+ which only `/hooks` shows.
552
554
 
553
555
  A fourth state is not one of them and is not reported as one: the host emits no
554
556
  event when it has nothing to announce. The gate therefore asks not "does anything
@@ -561,10 +563,11 @@ or an empty `~/.claude/CLAUDE.md` fires no event however the hook is wired, so
561
563
  the missing scan there is evidence of nothing. The trade this accepts is a false
562
564
  NEGATIVE: a repo whose announced instruction files all sit in subdirectories —
563
565
  a monorepo of per-package `CLAUDE.md` under a root carrying only `AGENTS.md`, on
564
- a machine with no user-global memory — loses the notice until a tool call touches
565
- one of those directories, which is what the gate's per-call `touchedDir` check
566
- recovers. Weighed against a false POSITIVE on every session launched outside a
567
- project, which trains the operator to skip the notice everywhere.
566
+ a machine with no user-global memory — gets no notice at all. A tool call that
567
+ touches such a directory is no evidence either: Claude Code loads the nested
568
+ file only after that call, so its PreToolUse runs before any scan could have.
569
+ Weighed against a false POSITIVE on every session launched outside a project,
570
+ which trains the operator to skip the notice everywhere.
568
571
 
569
572
  That table is a claim about someone else's product, so the event that names a
570
573
  loaded file is also what falsifies it. `contextScopeContradiction` checks every
@@ -19,10 +19,13 @@ import {
19
19
  rmSync,
20
20
  statSync,
21
21
  } from "node:fs";
22
+ import { spawnSync } from "node:child_process";
22
23
  import { randomBytes } from "node:crypto";
23
24
  import { basename, join } from "node:path";
24
25
  import { homedir } from "node:os";
25
26
  import {
27
+ DISABLED_HOOKS_ENV,
28
+ disabledHooks,
26
29
  hookgateMarkerPath,
27
30
  lazyImport,
28
31
  markerIsTrusted,
@@ -340,12 +343,13 @@ export function recordInstructionsLoaded(sessionId) {
340
343
  */
341
344
  const EVENT_MIN_CLI_VERSION = "2.1.69";
342
345
 
346
+ /** The `--hook=` name of the InstructionsLoaded scanner. */
347
+ const LOADED_SCAN_HOOK = "scan-loaded-instructions";
348
+
343
349
  /**
344
350
  * Companion marker: this session already found the LAUNCH set empty — that set
345
351
  * (which files load at session start) cannot change mid-session, so a second
346
- * glob of `dir` can never change that half of the answer. It says nothing about
347
- * a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
348
- * covers that half fresh on every call instead.
352
+ * glob of `dir` can never change the answer.
349
353
  * @param {string} [sessionId]
350
354
  * @returns {string}
351
355
  */
@@ -455,36 +459,106 @@ function userGlobalLaunchHasContent(env = process.env) {
455
459
  );
456
460
  }
457
461
 
462
+ /**
463
+ * The running Claude Code's version as `[major, minor, patch]`, or null when it
464
+ * cannot be read. Asks the binary rather than `CLAUDE_CODE_VERSION`: a remote
465
+ * host hands hooks a stale value there (2.1.42 under a 2.1.304 CLI).
466
+ * `CLAUDE_CODE_EXECPATH` names the binary running this session. Without it the
467
+ * answer is null: PATH's `claude` may be another install, and reporting its
468
+ * version as this session's would name a false cause. On an npm install that
469
+ * path is `node`, whose `v…` output also reads as null.
470
+ * @param {Record<string, string | undefined>} [env]
471
+ * @returns {number[] | null}
472
+ */
473
+ function runningCliVersion(env = process.env) {
474
+ if (!env.CLAUDE_CODE_EXECPATH) return null;
475
+ const result = spawnSync(env.CLAUDE_CODE_EXECPATH, ["--version"], {
476
+ encoding: "utf8",
477
+ timeout: 2000,
478
+ stdio: ["ignore", "pipe", "ignore"],
479
+ });
480
+ const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(result.stdout ?? "");
481
+ return match ? match.slice(1).map(Number) : null;
482
+ }
483
+
484
+ /**
485
+ * Whether `version` is older than EVENT_MIN_CLI_VERSION.
486
+ * @param {number[]} version
487
+ * @returns {boolean}
488
+ */
489
+ function predatesEvent(version) {
490
+ const floor = EVENT_MIN_CLI_VERSION.split(".").map(Number);
491
+ for (let i = 0; i < floor.length; i++)
492
+ if (version[i] !== floor[i]) return version[i] < floor[i];
493
+ return false;
494
+ }
495
+
496
+ /**
497
+ * The causes of a missing InstructionsLoaded scan that this hook could NOT rule
498
+ * out, each with the command that settles it. The disable list and the CLI
499
+ * version are checked here; only the wiring is left for `/hooks`. A cause that
500
+ * is confirmed is returned alone, since it explains the gap by itself.
501
+ * @param {Record<string, string | undefined>} env
502
+ * @param {(env: Record<string, string | undefined>) => number[] | null} cliVersion
503
+ * @returns {string}
504
+ */
505
+ function unexplainedGapCauses(env, cliVersion) {
506
+ // A real list of hook names never reaches this one-name `known`, so the
507
+ // unknown-name report would misfire on every other entry: it is silenced.
508
+ if (disabledHooks([LOADED_SCAN_HOOK], env, () => {}).size > 0)
509
+ return `${LOADED_SCAN_HOOK} is switched off in ${DISABLED_HOOKS_ENV}; removing it from that list restores the scan.`;
510
+ const unwired =
511
+ `this host never wired the InstructionsLoaded event to ${LOADED_SCAN_HOOK} ` +
512
+ "(the `/hooks` command lists what this session registered; wiring it " +
513
+ "restores the scan), or the hook crashed before it ran";
514
+ const version = cliVersion(env);
515
+ if (version === null)
516
+ return (
517
+ `${LOADED_SCAN_HOOK} is not disabled. Either ${unwired}, or this Claude ` +
518
+ `Code is older than ${EVENT_MIN_CLI_VERSION}, the first build that emits ` +
519
+ "the event (its version could not be read: run `claude --version`)."
520
+ );
521
+ if (predatesEvent(version))
522
+ return (
523
+ `this Claude Code is ${version.join(".")}, older than ` +
524
+ `${EVENT_MIN_CLI_VERSION}, the first build that emits the event; ` +
525
+ "upgrading restores the scan."
526
+ );
527
+ return (
528
+ `${LOADED_SCAN_HOOK} is not disabled and Claude Code ${version.join(".")} ` +
529
+ `emits the event, so either ${unwired}.`
530
+ );
531
+ }
532
+
458
533
  /**
459
534
  * The one-time context line for a session where no InstructionsLoaded scan ran,
460
535
  * or null when the scan has been seen, the notice already ran this session, the
461
- * host's cold-start marker says setup is still running, or neither launch nor
462
- * `touchedDir` could have fired the event.
536
+ * host's cold-start marker says setup is still running, or nothing announced at
537
+ * launch could have fired the event.
463
538
  *
464
539
  * PURE for the notice: nothing is recorded until the caller confirms it landed
465
- * in a returned response. Launch-emptiness is cached once found — fixed for the
466
- * session — but `touchedDir` is re-checked every call: the claim below is about
467
- * SUBDIRECTORY files, so an empty launch must not silence a later call whose
468
- * tool touched a directory that DOES carry real, unscanned content.
540
+ * in a returned response. Launch-emptiness is cached once found, since the
541
+ * launch set is fixed for the session.
469
542
  *
470
- * SessionStart scans what loads at launch; a subdirectory's file is scanned by
471
- * the event; no scan means nothing says so — unless nothing touched so far held
472
- * a file the event would have ANNOUNCED, with bytes in it. The three remaining
473
- * causes are named together, since
474
- * the marker cannot tell them apart: an unwired event, a Claude Code older than
475
- * EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
543
+ * Launch is the only evidence read. Claude Code fires the session_start events
544
+ * before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
545
+ * the tool call that reaches it, so a PreToolUse can never already see that
546
+ * file's scan, however the hook is wired.
476
547
  * @param {string} [sessionId] the harness's session identity (see
477
548
  * instructionsLoadedFile)
478
549
  * @param {string} [dir] the project root to check (injectable; defaults to
479
550
  * the real project)
480
- * @param {string} [touchedDir] this tool call's own target directory, when
481
- * known — re-checked every call, never cached
551
+ * @param {{
552
+ * env?: Record<string, string | undefined>,
553
+ * cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
554
+ * }} [options] injectable for tests; any other value, such as a directory
555
+ * string from an older caller, reads as no options
482
556
  * @returns {string | null}
483
557
  */
484
558
  export function instructionsLoadedGapNotice(
485
559
  sessionId,
486
560
  dir = PROJECT_DIR,
487
- touchedDir,
561
+ { env = process.env, cliVersion = runningCliVersion } = {},
488
562
  ) {
489
563
  if (instructionsLoadedSeen(sessionId)) return null;
490
564
  if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
@@ -497,31 +571,20 @@ export function instructionsLoadedGapNotice(
497
571
  // notice for every later session on the host.
498
572
  const marker = hookgateMarkerPath();
499
573
  if (markerIsTrusted(marker) && probeSetupAlive(marker)) return null;
500
- const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
501
- const launchHasBytes =
502
- !launchCached &&
503
- (anyFileHasBytes(announcedLaunchFiles(dir)) ||
504
- userGlobalLaunchHasContent());
505
- if (!launchCached && !launchHasBytes)
574
+ if (markerIsTrusted(launchEmptyFile(sessionId))) return null;
575
+ if (
576
+ !anyFileHasBytes(announcedLaunchFiles(dir)) &&
577
+ !userGlobalLaunchHasContent(env)
578
+ ) {
506
579
  writeSentinelFile(launchEmptyFile(sessionId));
507
- const touchedHasBytes =
508
- touchedDir !== undefined &&
509
- anyFileHasBytes(announcedLaunchFiles(touchedDir));
510
- if (!launchHasBytes && !touchedHasBytes) return null;
580
+ return null;
581
+ }
511
582
  return (
512
583
  "agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
513
584
  "instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
514
585
  "directory-scoped rule) are reaching the model unscanned for hidden " +
515
586
  "Unicode — the session-start scan covers only the files loaded at launch. " +
516
- "Tell the user, and name all three causes with the command that decides " +
517
- "each: this host never wired the InstructionsLoaded event to " +
518
- "scan-loaded-instructions (the `/hooks` command lists what this session " +
519
- "actually registered, whichever config dir or plugin root the host uses; " +
520
- "wiring it restores the coverage), a Claude Code " +
521
- `older than ${EVENT_MIN_CLI_VERSION}, the first build that emits the event ` +
522
- "(`claude --version`; upgrading restores it), or scan-loaded-instructions " +
523
- "switched off in AGENT_SANITIZER_DISABLED_HOOKS (`echo " +
524
- "$AGENT_SANITIZER_DISABLED_HOOKS`)."
587
+ `Tell the user: ${unexplainedGapCauses(env, cliVersion)}`
525
588
  );
526
589
  }
527
590
 
@@ -29,7 +29,6 @@
29
29
  */
30
30
  import { createRequire } from "node:module";
31
31
  import { readFileSync } from "node:fs";
32
- import { dirname } from "node:path";
33
32
  import {
34
33
  isMain,
35
34
  lazyImport,
@@ -438,40 +437,6 @@ export function preToolUseLayers(
438
437
  );
439
438
  }
440
439
 
441
- // The tools whose path field Claude Code sends as an absolute path by
442
- // contract, so reading it needs no cwd to resolve against. Bounded to these:
443
- // Glob/Grep's `path` can be relative or omitted (defaults to a cwd this
444
- // payload does not carry), and Bash has no reliable target at all — the same
445
- // carve-out WRITE_SHAPED_TOOLS below takes for Bash writes.
446
- const PATH_FIELD_BY_TOOL = /** @type {Record<string, string>} */ (
447
- Object.freeze({
448
- Read: "file_path",
449
- Edit: "file_path",
450
- Write: "file_path",
451
- MultiEdit: "file_path",
452
- NotebookEdit: "notebook_path",
453
- })
454
- );
455
-
456
- /**
457
- * The directory THIS tool call targets, for the InstructionsLoaded gap-notice
458
- * check — or undefined when the tool carries no reliable absolute path. An
459
- * imprecise guess here only ever WIDENS coverage (see instructionsLoadedGapNotice's
460
- * `touchedDir`): missing a real target loses nothing this check did not
461
- * already lack, and there is no wrong-directory case that suppresses a real
462
- * finding.
463
- * @param {string} tool
464
- * @param {any} toolInput
465
- * @returns {string | undefined}
466
- */
467
- function toolTargetDir(tool, toolInput) {
468
- const field = PATH_FIELD_BY_TOOL[tool];
469
- const path = field && toolInput?.[field];
470
- return typeof path === "string" && path.startsWith("/")
471
- ? dirname(path)
472
- : undefined;
473
- }
474
-
475
440
  /**
476
441
  * Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
477
442
  * null for a clean no-op. Throws only if a stage's engine throws; the caller
@@ -523,11 +488,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
523
488
  // the notice was surfaced: a rehydrate deny below returns before the response
524
489
  // is assembled, and recording here would burn the session's one report on a
525
490
  // call that never carried it.
526
- const gapNotice = instructionsLoadedGapNotice(
527
- input.session_id,
528
- undefined,
529
- toolTargetDir(tool, toolInput),
530
- );
491
+ const gapNotice = instructionsLoadedGapNotice(input.session_id);
531
492
  if (gapNotice !== null) contexts.push(gapNotice);
532
493
 
533
494
  // The rewriting stages, run by the declared pipeline: the driver — not this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.66.4",
3
+ "version": "2.66.5",
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": {
@@ -82,9 +82,7 @@ export function recordInstructionsLoaded(sessionId?: string): void;
82
82
  /**
83
83
  * Companion marker: this session already found the LAUNCH set empty — that set
84
84
  * (which files load at session start) cannot change mid-session, so a second
85
- * glob of `dir` can never change that half of the answer. It says nothing about
86
- * a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
87
- * covers that half fresh on every call instead.
85
+ * glob of `dir` can never change the answer.
88
86
  * @param {string} [sessionId]
89
87
  * @returns {string}
90
88
  */
@@ -105,30 +103,32 @@ export function launchInstructionFiles(dir: string): string[];
105
103
  /**
106
104
  * The one-time context line for a session where no InstructionsLoaded scan ran,
107
105
  * or null when the scan has been seen, the notice already ran this session, the
108
- * host's cold-start marker says setup is still running, or neither launch nor
109
- * `touchedDir` could have fired the event.
106
+ * host's cold-start marker says setup is still running, or nothing announced at
107
+ * launch could have fired the event.
110
108
  *
111
109
  * PURE for the notice: nothing is recorded until the caller confirms it landed
112
- * in a returned response. Launch-emptiness is cached once found — fixed for the
113
- * session — but `touchedDir` is re-checked every call: the claim below is about
114
- * SUBDIRECTORY files, so an empty launch must not silence a later call whose
115
- * tool touched a directory that DOES carry real, unscanned content.
110
+ * in a returned response. Launch-emptiness is cached once found, since the
111
+ * launch set is fixed for the session.
116
112
  *
117
- * SessionStart scans what loads at launch; a subdirectory's file is scanned by
118
- * the event; no scan means nothing says so — unless nothing touched so far held
119
- * a file the event would have ANNOUNCED, with bytes in it. The three remaining
120
- * causes are named together, since
121
- * the marker cannot tell them apart: an unwired event, a Claude Code older than
122
- * EVENT_MIN_CLI_VERSION, or the hook disabled via AGENT_SANITIZER_DISABLED_HOOKS.
113
+ * Launch is the only evidence read. Claude Code fires the session_start events
114
+ * before the first PreToolUse, but a subdirectory's CLAUDE.md loads only AFTER
115
+ * the tool call that reaches it, so a PreToolUse can never already see that
116
+ * file's scan, however the hook is wired.
123
117
  * @param {string} [sessionId] the harness's session identity (see
124
118
  * instructionsLoadedFile)
125
119
  * @param {string} [dir] the project root to check (injectable; defaults to
126
120
  * the real project)
127
- * @param {string} [touchedDir] this tool call's own target directory, when
128
- * known — re-checked every call, never cached
121
+ * @param {{
122
+ * env?: Record<string, string | undefined>,
123
+ * cliVersion?: (env: Record<string, string | undefined>) => number[] | null,
124
+ * }} [options] injectable for tests; any other value, such as a directory
125
+ * string from an older caller, reads as no options
129
126
  * @returns {string | null}
130
127
  */
131
- export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, touchedDir?: string): string | null;
128
+ export function instructionsLoadedGapNotice(sessionId?: string, dir?: string, { env, cliVersion }?: {
129
+ env?: Record<string, string | undefined>;
130
+ cliVersion?: (env: Record<string, string | undefined>) => number[] | null;
131
+ }): string | null;
132
132
  /**
133
133
  * Record that the gap notice above was surfaced, so it rides on ONE tool call
134
134
  * rather than every one — the per-call repeat is what trains a reader to skip it.