agent-sanitizer 2.66.3 → 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
@@ -797,7 +800,7 @@ positive costs a sentence of context, never a mangled input):
797
800
  the identical Write confirms and passes. The confirmation is the model's
798
801
  deliberate retry — never a human permission prompt — held as a
799
802
  consumed-on-use, TTL-bounded sentinel (keyed to path + content + the dropped
800
- values) via the same squat-resistant `$TMPDIR` helpers as the invisible-char
803
+ values) in `$TMPDIR` via the same squat-resistant helpers as the invisible-char
801
804
  gate. Tracked files, secret-free files, failed git probes and unmappable
802
805
  views all skip the guard (fail open). "Tracked" approximates "recoverable":
803
806
  an uncommitted secret line on a tracked file is an accepted gap — the
@@ -927,6 +930,16 @@ it, so what anchors its trust is worth stating exactly.
927
930
  under `TMPDIR` is read only when this user owns it with owner-only write, so a
928
931
  squatted shared-temp entry cannot pre-suppress the warning that a call ran
929
932
  unsanitized.
933
+ - **Session state lives under that directory too, not in `$TMPDIR`.** The
934
+ InstructionsLoaded marker, the invisible-char gate's findings and ack, and the
935
+ Layer-2 reveal spans sit in `${CLAUDE_PLUGIN_DATA}/session-state`, created mode
936
+ 700 and used only when both it and the data directory are real directories
937
+ this user owns with no group/other write (`privateDirAt`) and the leaf is
938
+ writable. An OS temp cleaner (macOS dirhelper purges `$TMPDIR` entries unread
939
+ for 3 days) would otherwise delete a live session's marker — a false "no
940
+ InstructionsLoaded scan has run" notice — or its findings, lifting the gate.
941
+ A host with no plugin data directory, or one that fails those checks, falls
942
+ back to `$TMPDIR` and keeps that exposure.
930
943
  - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
931
944
  a binary. That leaves the node path running the shipped bundle and the
932
945
  compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
@@ -1,15 +1,21 @@
1
1
  /** Shared I/O helpers for the Claude Code hook scripts. */
2
2
 
3
3
  import {
4
+ accessSync,
5
+ constants as fsConstants,
4
6
  openSync,
5
7
  closeSync,
6
8
  lstatSync,
9
+ lutimesSync,
10
+ mkdirSync,
7
11
  readFileSync,
8
12
  unlinkSync,
9
13
  writeFileSync,
10
14
  } from "node:fs";
11
15
  import { spawnSync } from "node:child_process";
12
16
  import { createHash } from "node:crypto";
17
+ import { tmpdir } from "node:os";
18
+ import { isAbsolute, join } from "node:path";
13
19
  import { pathToFileURL } from "node:url";
14
20
  import { excludeProvisioning } from "./hook-timing.mjs";
15
21
  import { readKnob } from "./knobs.mjs";
@@ -695,6 +701,55 @@ export const PROJECT_HASH = createHash("sha256")
695
701
  .digest("hex")
696
702
  .slice(0, 8);
697
703
 
704
+ /**
705
+ * The directory the session-lifetime stores live under: the alert markers and
706
+ * findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
707
+ * outlive the longest session, which $TMPDIR does not promise — macOS's daily
708
+ * dirhelper job deletes anything in the per-user `/var/folders/.../T/` not
709
+ * accessed for 3 days, and a marker written at launch and only ever `lstat`ed
710
+ * then vanishes from a session still running, its absence read as "never ran".
711
+ *
712
+ * `$CLAUDE_PLUGIN_DATA/session-state` when both levels are private to this uid
713
+ * ({@link privateDirAt}): write on the plugin data dir alone is enough to swap
714
+ * `session-state` out. Each level is created 0700 when absent — so the
715
+ * launch-time InstructionsLoaded event and the first tool call resolve the same
716
+ * root whichever runs first — and checked BEFORE the next is created inside it,
717
+ * so a symlinked or shared plugin data dir never gets a directory planted in
718
+ * its target. A root this process cannot write — a read-only mount, an owner
719
+ * bit removed — is no root: every marker write would fail and read as "the
720
+ * event never fired". Otherwise $TMPDIR, where the stores' squat checks apply.
721
+ * @param {Record<string, string | undefined>} env
722
+ * @returns {string}
723
+ */
724
+ export function resolveStateRoot(env) {
725
+ const data = env.CLAUDE_PLUGIN_DATA;
726
+ // A relative path resolves against each hook process's cwd, so two processes
727
+ // would disagree on the root, and it could land inside a scanned tree.
728
+ if (!data || !isAbsolute(data)) return tmpdir();
729
+ const root = join(data, "session-state");
730
+ for (const dir of [data, root]) {
731
+ try {
732
+ mkdirSync(dir, { mode: 0o700 });
733
+ } catch (err) {
734
+ // Anything but "already there" — a read-only or unreachable plugin data
735
+ // dir — is an ordinary host: $TMPDIR still holds the state, at its
736
+ // shorter lifetime.
737
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "EEXIST")
738
+ return tmpdir();
739
+ }
740
+ if (!privateDirAt(dir)) return tmpdir();
741
+ }
742
+ try {
743
+ accessSync(root, fsConstants.W_OK | fsConstants.X_OK);
744
+ } catch {
745
+ return tmpdir();
746
+ }
747
+ return root;
748
+ }
749
+
750
+ /** {@link resolveStateRoot} for this process, resolved once at load. */
751
+ export const STATE_ROOT = resolveStateRoot(process.env);
752
+
698
753
  /** The marker filename stem; the project directory is appended to it. */
699
754
  const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
700
755
 
@@ -1067,7 +1122,7 @@ export function currentUid() {
1067
1122
 
1068
1123
  /**
1069
1124
  * Whether `st` (an lstat result) is owned by this process's uid. THE ownership
1070
- * predicate for every $TMPDIR store these hooks trust — a second spelling is how
1125
+ * predicate for every state store these hooks trust — a second spelling is how
1071
1126
  * four call sites came to share the passwd-lookup crash {@link currentUid}
1072
1127
  * documents. False when the platform has no uid model: ownership that cannot be
1073
1128
  * proven is not granted. `uid` is a seam for a test that must model another
@@ -1084,10 +1139,11 @@ export function ownedByCurrentUser(st, uid = currentUid()) {
1084
1139
  * Whether `st` (an lstat result) is a directory no untrusted uid can change: a
1085
1140
  * real directory (lstat, so a planted symlink is not one), owned by this uid —
1086
1141
  * or by root where `rootMayOwn` — with no group/other write bit. THE trust test
1087
- * for every private directory these hooks keep in $TMPDIR. Write is the
1088
- * property because creating, unlinking or replacing an entry needs write on its
1089
- * parent: a co-tenant holding it can delete the finding a gate needs, swap a
1090
- * reveal file, or rebind the redactor socket to a listener that redacts nothing.
1142
+ * for every private directory these hooks keep, under STATE_ROOT or $TMPDIR.
1143
+ * Write is the property because creating, unlinking or replacing an entry needs
1144
+ * write on its parent: a co-tenant holding it can delete the finding a gate
1145
+ * needs, swap a reveal file, or rebind the redactor socket to a listener that
1146
+ * redacts nothing.
1091
1147
  * Root ownership is strictly stronger than ours, so a deployment may hand a dir
1092
1148
  * to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
1093
1149
  * @param {{ isDirectory(): boolean, uid: number, mode: number }} st
@@ -1104,6 +1160,44 @@ export function isPrivateDir(
1104
1160
  return st.isDirectory() && owner && (st.mode & 0o022) === 0;
1105
1161
  }
1106
1162
 
1163
+ /**
1164
+ * Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
1165
+ * the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted at
1166
+ * a predictable path is judged on ITSELF: followed, it would let a co-tenant aim
1167
+ * a reader at unrelated files this uid owns (the alert gate splices those bytes
1168
+ * into a permission prompt). An absent path is simply not one.
1169
+ * @param {string} path
1170
+ * @param {{ uid?: number | null }} [opts] as in {@link isPrivateDir}
1171
+ * @returns {boolean}
1172
+ */
1173
+ export function privateDirAt(path, opts) {
1174
+ let st;
1175
+ try {
1176
+ st = lstatSync(path);
1177
+ } catch {
1178
+ return false;
1179
+ }
1180
+ return isPrivateDir(st, opts);
1181
+ }
1182
+
1183
+ /**
1184
+ * Stamp a state file the caller has just trusted as used now, so a live
1185
+ * session's state never looks idle to an age-based sweep — another session's
1186
+ * {@link STATE_ROOT} sweep (mtime) or an OS temp cleaner (atime). `lutimes`, so
1187
+ * a symlink swapped in after the caller's check is stamped itself, never its
1188
+ * target. A path a parallel sweep removed after that check is simply gone.
1189
+ * @param {string} path
1190
+ * @returns {void}
1191
+ */
1192
+ export function touchLiveState(path) {
1193
+ const now = new Date();
1194
+ try {
1195
+ lutimesSync(path, now, now);
1196
+ } catch (err) {
1197
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") throw err;
1198
+ }
1199
+ }
1200
+
1107
1201
  /**
1108
1202
  * Create a presence sentinel at `path` without following a symlink a co-tenant
1109
1203
  * may have pre-planted there. These sentinels live at predictable, world-visible
@@ -19,18 +19,24 @@ 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
- import { homedir, tmpdir } from "node:os";
25
+ import { homedir } from "node:os";
25
26
  import {
27
+ DISABLED_HOOKS_ENV,
28
+ disabledHooks,
26
29
  hookgateMarkerPath,
27
30
  lazyImport,
28
31
  markerIsTrusted,
29
- isPrivateDir,
32
+ ownedByCurrentUser,
33
+ privateDirAt,
30
34
  probeSetupAlive,
31
35
  PROJECT_DIR,
32
36
  PROJECT_HASH,
33
37
  scrubUntrustedText,
38
+ STATE_ROOT,
39
+ touchLiveState,
34
40
  writeFileNoFollow,
35
41
  writeSentinelFile,
36
42
  } from "./hook-io.mjs";
@@ -59,15 +65,28 @@ const { applyLayer1WellFormed } =
59
65
  await lazyImport("agent-sanitizer")
60
66
  );
61
67
 
68
+ /** Every alert artifact name's start, less the project hash that follows it. */
69
+ const ALERT_STEM = ".claude-invisible-char-alert-";
70
+
62
71
  /**
63
72
  * The path prefix every alert artifact of this PROJECT shares. Never a file
64
73
  * itself — only {@link sessionPrefix} and the sweep read it — so that one
65
- * `startsWith` covers every artifact the sweep must age out.
74
+ * `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
75
+ * not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
76
+ * read as the InstructionsLoaded scan never having run, and deleting its
77
+ * findings would lift the gate mid-session.
78
+ */
79
+ export const ALERT_BASE = join(STATE_ROOT, `${ALERT_STEM}${PROJECT_HASH}`);
80
+
81
+ /**
82
+ * Whether `name` is a session artifact of ANY project: {@link ALERT_BASE}'s
83
+ * name, whatever its project hash, then `.s-`.
84
+ * @param {string} name
85
+ * @returns {boolean}
66
86
  */
67
- export const ALERT_BASE = join(
68
- tmpdir(),
69
- `.claude-invisible-char-alert-${PROJECT_HASH}`,
70
- );
87
+ const isAnyProjectSessionEntry = (name) =>
88
+ name.startsWith(ALERT_STEM) &&
89
+ /^[0-9a-f]{8}\.s-/u.test(name.slice(ALERT_STEM.length));
71
90
 
72
91
  /**
73
92
  * The path prefix every alert artifact of ONE session under this project shares.
@@ -86,7 +105,7 @@ export const ALERT_BASE = join(
86
105
  export function sessionPrefix(sessionId) {
87
106
  // The id becomes a path component, so anything outside this class — a `/` in
88
107
  // a hostile session id above all — is folded away rather than escaping
89
- // $TMPDIR. A host that exports no session id falls back to one shared name,
108
+ // STATE_ROOT. A host that exports no session id falls back to one shared name,
90
109
  // whose findings and ack are bounded by FALLBACK_TTL_MS instead.
91
110
  const key =
92
111
  (sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
@@ -138,12 +157,20 @@ export function instructionsLoadedNoticeFile(sessionId) {
138
157
  * lazily-loaded instruction files are being scanned at all. Ownership-validated
139
158
  * like every other marker here: a co-tenant could otherwise plant the
140
159
  * predictable path and suppress the notice below, which is the whole signal that
141
- * nested files are going unscanned.
160
+ * nested files are going unscanned. A marker that is seen is stamped as used:
161
+ * it is written once at launch, so otherwise a session outliving
162
+ * MARKER_TTL_MS would have it swept by another session's SessionStart and read
163
+ * its absence as the event never having fired.
142
164
  * @param {string} [sessionId]
143
165
  * @returns {boolean}
144
166
  */
145
167
  export function instructionsLoadedSeen(sessionId) {
146
- return markerIsTrusted(instructionsLoadedFile(sessionId));
168
+ const marker = instructionsLoadedFile(sessionId);
169
+ if (!markerIsTrusted(marker)) return false;
170
+ // Not the shared no-session marker: every session-less host would refresh
171
+ // it, and MARKER_TTL_MS is what keeps it from silencing every later session.
172
+ if (sessionPrefix(sessionId) !== sessionPrefix()) touchLiveState(marker);
173
+ return true;
147
174
  }
148
175
 
149
176
  /** How long a past session's artifacts are kept before a later session sweeps. */
@@ -182,8 +209,8 @@ const FALLBACK_TTL_MS = 30 * 60 * 1000;
182
209
  * this `lstat` reads as expired, which is what every caller wants of a file that
183
210
  * is no longer there: the sweep's unlink becomes a no-op, the gate's reader skips
184
211
  * it, and an ack that vanished stops suppressing the ask. Propagating instead
185
- * would abort recordInstructionsLoaded on a benign $TMPDIR race and render the
186
- * false "instruction file was NOT scanned" fault.
212
+ * would abort recordInstructionsLoaded on a benign STATE_ROOT race and render
213
+ * the false "instruction file was NOT scanned" fault.
187
214
  *
188
215
  * `lstat`, so a planted symlink is judged on itself.
189
216
  * @param {string} path
@@ -206,30 +233,12 @@ function withinTtl(path, ttlMs) {
206
233
  const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
207
234
 
208
235
  /**
209
- * Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
210
- * the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted
211
- * at the predictable alert-dir path is judged on ITSELF: followed, it would let
212
- * a co-tenant aim the gate's reader at a directory of unrelated files this uid
213
- * owns and splice their bytes into a permission prompt. A group/other-writable
214
- * one is refused too: a co-tenant could unlink the finding the gate needs.
215
- * @param {string} path
216
- * @returns {boolean}
217
- */
218
- function dirIsTrusted(path) {
219
- let st;
220
- try {
221
- st = lstatSync(path);
222
- } catch {
223
- return false;
224
- }
225
- return isPrivateDir(st);
226
- }
227
-
228
- /**
229
- * Delete this project's artifacts from OTHER sessions once they are older than
236
+ * Delete OTHER sessions' artifacts, of every project, once they are older than
230
237
  * the TTL. The current session's own prefix is skipped, so the sweep can never
231
238
  * answer its own question wrong; every other session's files are past history
232
- * that nothing reads.
239
+ * that nothing reads — a live one keeps its marker and findings fresh on read.
240
+ * Every project, because STATE_ROOT may be persistent: a project never reopened
241
+ * would otherwise keep its artifacts for good.
233
242
  *
234
243
  * This replaces the destructive SessionStart clear: with the store session-keyed
235
244
  * there is nothing to reset, only old state to age out.
@@ -237,28 +246,28 @@ function dirIsTrusted(path) {
237
246
  * @returns {void}
238
247
  */
239
248
  export function sweepStaleSessions(sessionId) {
240
- const dir = tmpdir();
241
- const prefix = `${basename(ALERT_BASE)}.s-`;
249
+ const dir = STATE_ROOT;
242
250
  const keep = basename(sessionPrefix(sessionId));
243
251
  const cutoff = Date.now() - MARKER_TTL_MS;
244
252
  for (const name of readdirSync(dir)) {
245
- if (!name.startsWith(prefix)) continue;
253
+ if (!isAnyProjectSessionEntry(name)) continue;
246
254
  // Whole-segment match on the keep prefix, not a bare startsWith: session key
247
255
  // "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
248
256
  if (name === keep || name.startsWith(`${keep}.`)) continue;
249
257
  const path = join(dir, name);
250
258
  try {
251
- // lstat, not stat: a squatted symlink at a predictable $TMPDIR path must
252
- // be judged on ITSELF, not on whatever it points at.
253
- if (lstatSync(path).mtimeMs >= cutoff) continue;
259
+ // lstat, not stat: a squatted symlink at a predictable path is judged on
260
+ // ITSELF. Ownership first: in a shared /tmp another uid's entry is not
261
+ // ours to remove, and an rmSync on its file fails ENOTDIR, not EPERM.
262
+ const st = lstatSync(path);
263
+ if (!ownedByCurrentUser(st) || st.mtimeMs >= cutoff) continue;
254
264
  rmSync(path, { recursive: true, force: true });
255
265
  } catch (err) {
256
266
  // ENOENT: a parallel session swept this entry between readdir and lstat.
257
- // EPERM/EACCES: a co-tenant's entry sharing the prefix, which is not ours
258
- // to remove. Both are benign races on a shared $TMPDIR, and a throw here
259
- // would abort recordInstructionsLoaded and render the "instruction file
260
- // was NOT scanned" fault on a session that WAS scanned. Anything else is
261
- // a bug in this sweep and propagates.
267
+ // EPERM/EACCES: an entry of ours a co-tenant's directory shields. A throw
268
+ // here would abort recordInstructionsLoaded and render the "instruction
269
+ // file was NOT scanned" fault on a session that WAS scanned. Anything else
270
+ // is a bug in this sweep and propagates.
262
271
  const code = /** @type {NodeJS.ErrnoException} */ (err).code;
263
272
  if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
264
273
  }
@@ -268,7 +277,7 @@ export function sweepStaleSessions(sessionId) {
268
277
 
269
278
  /**
270
279
  * Remove the shared-prefix artifacts the reader has stopped honouring, so they
271
- * stop occupying $TMPDIR too. The loop above cannot: it skips the current
280
+ * stop occupying STATE_ROOT too. The loop above cannot: it skips the current
272
281
  * session's prefix, which on a host with no session id IS this one.
273
282
  *
274
283
  * Mirrors what {@link invisibleCharAlert} and {@link alertAcknowledged} read.
@@ -281,14 +290,14 @@ export function sweepStaleSessions(sessionId) {
281
290
  * scanned, or re-glob a launch this session already found empty. They still go,
282
291
  * at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
283
292
  * out at, and far past any session's life — because the loop skips this prefix
284
- * and would otherwise leave a session-less host's markers in $TMPDIR forever,
285
- * with the gap notice suppressed on every later session.
293
+ * and would otherwise leave a session-less host's markers in STATE_ROOT
294
+ * forever, with the gap notice suppressed on every later session.
286
295
  * @param {string} [sessionId]
287
296
  * @returns {void}
288
297
  */
289
298
  function sweepStaleFallback(sessionId) {
290
299
  const dir = alertDir();
291
- const inherited = alertDir(sessionId) !== dir && dirIsTrusted(dir);
300
+ const inherited = alertDir(sessionId) !== dir && privateDirAt(dir);
292
301
  const entries = inherited
293
302
  ? readdirSync(dir).map((name) => join(dir, name))
294
303
  : [];
@@ -311,7 +320,7 @@ function sweepStaleFallback(sessionId) {
311
320
 
312
321
  /**
313
322
  * Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
314
- * write (see writeSentinelFile) at a predictable $TMPDIR path.
323
+ * write (see writeSentinelFile) at a predictable STATE_ROOT path.
315
324
  *
316
325
  * The event fires once per instruction file loaded, so the already-recorded case
317
326
  * returns without a write — and the stale-session sweep rides the FIRST fire of
@@ -334,12 +343,13 @@ export function recordInstructionsLoaded(sessionId) {
334
343
  */
335
344
  const EVENT_MIN_CLI_VERSION = "2.1.69";
336
345
 
346
+ /** The `--hook=` name of the InstructionsLoaded scanner. */
347
+ const LOADED_SCAN_HOOK = "scan-loaded-instructions";
348
+
337
349
  /**
338
350
  * Companion marker: this session already found the LAUNCH set empty — that set
339
351
  * (which files load at session start) cannot change mid-session, so a second
340
- * glob of `dir` can never change that half of the answer. It says nothing about
341
- * a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
342
- * covers that half fresh on every call instead.
352
+ * glob of `dir` can never change the answer.
343
353
  * @param {string} [sessionId]
344
354
  * @returns {string}
345
355
  */
@@ -449,36 +459,106 @@ function userGlobalLaunchHasContent(env = process.env) {
449
459
  );
450
460
  }
451
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
+
452
533
  /**
453
534
  * The one-time context line for a session where no InstructionsLoaded scan ran,
454
535
  * or null when the scan has been seen, the notice already ran this session, the
455
- * host's cold-start marker says setup is still running, or neither launch nor
456
- * `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.
457
538
  *
458
539
  * PURE for the notice: nothing is recorded until the caller confirms it landed
459
- * in a returned response. Launch-emptiness is cached once found — fixed for the
460
- * session — but `touchedDir` is re-checked every call: the claim below is about
461
- * SUBDIRECTORY files, so an empty launch must not silence a later call whose
462
- * 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.
463
542
  *
464
- * SessionStart scans what loads at launch; a subdirectory's file is scanned by
465
- * the event; no scan means nothing says so — unless nothing touched so far held
466
- * a file the event would have ANNOUNCED, with bytes in it. The three remaining
467
- * causes are named together, since
468
- * the marker cannot tell them apart: an unwired event, a Claude Code older than
469
- * 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.
470
547
  * @param {string} [sessionId] the harness's session identity (see
471
548
  * instructionsLoadedFile)
472
549
  * @param {string} [dir] the project root to check (injectable; defaults to
473
550
  * the real project)
474
- * @param {string} [touchedDir] this tool call's own target directory, when
475
- * 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
476
556
  * @returns {string | null}
477
557
  */
478
558
  export function instructionsLoadedGapNotice(
479
559
  sessionId,
480
560
  dir = PROJECT_DIR,
481
- touchedDir,
561
+ { env = process.env, cliVersion = runningCliVersion } = {},
482
562
  ) {
483
563
  if (instructionsLoadedSeen(sessionId)) return null;
484
564
  if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
@@ -491,31 +571,20 @@ export function instructionsLoadedGapNotice(
491
571
  // notice for every later session on the host.
492
572
  const marker = hookgateMarkerPath();
493
573
  if (markerIsTrusted(marker) && probeSetupAlive(marker)) return null;
494
- const launchCached = markerIsTrusted(launchEmptyFile(sessionId));
495
- const launchHasBytes =
496
- !launchCached &&
497
- (anyFileHasBytes(announcedLaunchFiles(dir)) ||
498
- userGlobalLaunchHasContent());
499
- if (!launchCached && !launchHasBytes)
574
+ if (markerIsTrusted(launchEmptyFile(sessionId))) return null;
575
+ if (
576
+ !anyFileHasBytes(announcedLaunchFiles(dir)) &&
577
+ !userGlobalLaunchHasContent(env)
578
+ ) {
500
579
  writeSentinelFile(launchEmptyFile(sessionId));
501
- const touchedHasBytes =
502
- touchedDir !== undefined &&
503
- anyFileHasBytes(announcedLaunchFiles(touchedDir));
504
- if (!launchHasBytes && !touchedHasBytes) return null;
580
+ return null;
581
+ }
505
582
  return (
506
583
  "agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
507
584
  "instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
508
585
  "directory-scoped rule) are reaching the model unscanned for hidden " +
509
586
  "Unicode — the session-start scan covers only the files loaded at launch. " +
510
- "Tell the user, and name all three causes with the command that decides " +
511
- "each: this host never wired the InstructionsLoaded event to " +
512
- "scan-loaded-instructions (the `/hooks` command lists what this session " +
513
- "actually registered, whichever config dir or plugin root the host uses; " +
514
- "wiring it restores the coverage), a Claude Code " +
515
- `older than ${EVENT_MIN_CLI_VERSION}, the first build that emits the event ` +
516
- "(`claude --version`; upgrading restores it), or scan-loaded-instructions " +
517
- "switched off in AGENT_SANITIZER_DISABLED_HOOKS (`echo " +
518
- "$AGENT_SANITIZER_DISABLED_HOOKS`)."
587
+ `Tell the user: ${unexplainedGapCauses(env, cliVersion)}`
519
588
  );
520
589
  }
521
590
 
@@ -534,14 +603,14 @@ export function recordInstructionsLoadedNotice(sessionId) {
534
603
  * The alert findings if invisible-char injection was detected in instruction
535
604
  * files and couldn't be auto-cleaned, else null.
536
605
  *
537
- * The store is a DIRECTORY of one file per finding, all at predictable,
538
- * world-visible $TMPDIR paths, so both the directory and every entry in it are
539
- * attacker-plantable: trust the directory only when it is a real directory this
540
- * uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
541
- * each entry only when markerIsTrusted confirms a regular file this uid owns,
542
- * then scrub the bytes through Layer-1 before any caller splices them into a
543
- * reason — the report would otherwise carry ANSI/invisible spoofing into the
544
- * model's context.
606
+ * The store is a DIRECTORY of one file per finding, all at predictable
607
+ * STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
608
+ * directory and every entry in it are attacker-plantable: trust the directory
609
+ * only when it is a real directory this uid owns (a symlink would let a
610
+ * co-tenant aim this reader at unrelated files), each entry only when
611
+ * markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
612
+ * through Layer-1 before any caller splices them into a reason — the report
613
+ * would otherwise carry ANSI/invisible spoofing into the model's context.
545
614
  * This session's store AND the shared `no-session` fallback, because a hook that
546
615
  * faults BEFORE it can parse its payload has no session identity to key by: its
547
616
  * finding lands in the fallback, and a strictly session-keyed read would leave
@@ -560,7 +629,10 @@ export function invisibleCharAlert(sessionId) {
560
629
  const dirs = own === alertDir() ? [own] : [own, alertDir()];
561
630
  const parts = [];
562
631
  for (const dir of dirs) {
563
- if (!dirIsTrusted(dir)) continue;
632
+ if (!privateDirAt(dir)) continue;
633
+ // A live session's findings must outlast any sweep that ages the store by
634
+ // its directory's timestamp: losing them lifts the gate mid-session.
635
+ if (dir === own) touchLiveState(dir);
564
636
  // Sorted so a multi-finding report reads the same on every call; the names
565
637
  // are random, so the order carries no meaning beyond being stable.
566
638
  for (const name of readdirSync(dir).sort()) {
@@ -583,7 +655,7 @@ export function invisibleCharAlert(sessionId) {
583
655
  * through a read-modify-write would let two hooks recording a finding at once
584
656
  * silently drop one of them; a fresh file per finding has no shared cell to
585
657
  * lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
586
- * predictable, world-visible $TMPDIR path.
658
+ * predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
587
659
  * @param {string} text
588
660
  * @param {string} [sessionId]
589
661
  * @returns {boolean} whether the finding was recorded
@@ -597,7 +669,7 @@ export function appendAlert(text, sessionId) {
597
669
  let usable;
598
670
  try {
599
671
  mkdirSync(dir, { recursive: true, mode: 0o700 });
600
- usable = dirIsTrusted(dir);
672
+ usable = privateDirAt(dir);
601
673
  } catch {
602
674
  usable = false;
603
675
  }
@@ -612,9 +684,10 @@ export function appendAlert(text, sessionId) {
612
684
  /**
613
685
  * True once the gate has surfaced its blocking ask this session. Validates
614
686
  * ownership (not mere existence): a co-tenant could pre-create the ack at its
615
- * predictable $TMPDIR path to permanently suppress the one-time blocking ask down
616
- * to the passive reminder, so trust the marker only when it is a regular file
617
- * this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
687
+ * predictable STATE_ROOT path to permanently suppress the one-time blocking ask
688
+ * down to the passive reminder, so trust the marker only when it is a regular
689
+ * file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
690
+ * it.
618
691
  *
619
692
  * On a host that exports no session id every session shares the fallback prefix,
620
693
  * so the ack has no session to end with and would suppress the one-time blocking
@@ -638,8 +711,8 @@ export function alertAcknowledged(sessionId) {
638
711
  * @returns {void}
639
712
  */
640
713
  export function acknowledgeAlert(sessionId) {
641
- // Symlink-safe presence write: the ack sits at a predictable $TMPDIR path a
642
- // co-tenant could pre-plant a symlink at (see writeSentinelFile).
714
+ // Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
715
+ // a co-tenant could pre-plant a symlink at (see writeSentinelFile).
643
716
  writeSentinelFile(alertAckFile(sessionId));
644
717
  }
645
718
 
@@ -35,7 +35,12 @@ import {
35
35
  encodeFrame,
36
36
  isPlainObject,
37
37
  } from "./redactor-wire.mjs";
38
- import { currentUid, isPrivateDir, ownedByCurrentUser } from "./hook-io.mjs";
38
+ import {
39
+ currentUid,
40
+ isPrivateDir,
41
+ ownedByCurrentUser,
42
+ privateDirAt,
43
+ } from "./hook-io.mjs";
39
44
  import { readKnob } from "./knobs.mjs";
40
45
 
41
46
  export { FRAME_CAP };
@@ -54,25 +59,6 @@ export { positiveMsOr } from "./knobs.mjs";
54
59
  const SOCKET_PATH_MAX = 103;
55
60
  const TEMP_BIND_NAME_LENGTH = 17;
56
61
 
57
- /**
58
- * Whether `root` is a real directory this uid owns that no group/other can
59
- * write. lstat, so a symlinked root is refused rather than followed. An absent
60
- * root is just not a candidate: this runs at module load, where a throw would
61
- * stop the hook loading at all.
62
- * @param {string} root
63
- * @param {number | null} uid
64
- * @returns {boolean}
65
- */
66
- function isPrivateRoot(root, uid) {
67
- let st;
68
- try {
69
- st = lstatSync(root);
70
- } catch {
71
- return false;
72
- }
73
- return isPrivateDir(st, { uid });
74
- }
75
-
76
62
  /**
77
63
  * The redactor socket path: one per user, shared by every session of that user
78
64
  * (the daemon redacts each REQUESTER's env secrets). The daemon binds it 0600
@@ -81,7 +67,7 @@ function isPrivateRoot(root, uid) {
81
67
  * 1. `_AGENT_SANITIZER_REDACTOR_SOCKET` — explicit override.
82
68
  * 2. `$XDG_RUNTIME_DIR`, then `$CLAUDE_PLUGIN_DATA` — per-user directories no
83
69
  * other uid can write, so a co-tenant cannot pre-create the socket dir under
84
- * them. Taken only if isPrivateRoot passes and the path fits sun_path.
70
+ * them. Taken only if privateDirAt passes and the path fits sun_path.
85
71
  * 3. `<tmpdir>/agent-sanitizer-redactor-<uid>` — on a shared /tmp a co-tenant
86
72
  * can still pre-create it; the daemon then refuses to serve and the client
87
73
  * refuses to connect (classifySocket), costing availability, never a secret.
@@ -94,7 +80,7 @@ export function resolveSocketPath(env, uid) {
94
80
  const override = readKnob("_AGENT_SANITIZER_REDACTOR_SOCKET", env);
95
81
  if (override) return override;
96
82
  for (const root of [env.XDG_RUNTIME_DIR, env.CLAUDE_PLUGIN_DATA]) {
97
- if (!root || !isPrivateRoot(root, uid)) continue;
83
+ if (!root || !privateDirAt(root, { uid })) continue;
98
84
  const dir = join(root, "agent-sanitizer-redactor");
99
85
  if (Buffer.byteLength(dir) + 1 + TEMP_BIND_NAME_LENGTH <= SOCKET_PATH_MAX)
100
86
  return join(dir, "redactor.sock");
@@ -9,8 +9,8 @@
9
9
  * instructions" envelope (REVEAL_READ_ENVELOPE) re-attached when that file is read.
10
10
  * Read is not untrusted ingress, so a Read of the sidecar already bypasses
11
11
  * Layer 2 (no re-splice); the carve-out's job is to mark the bytes untrusted.
12
- * The store is content-addressed (identical output dedupes) and lives under a
13
- * throwaway tmp dir; _AGENT_SANITIZER_REVEAL_DIR overrides the location.
12
+ * The store is content-addressed (identical output dedupes) and lives under
13
+ * STATE_ROOT; _AGENT_SANITIZER_REVEAL_DIR overrides the location.
14
14
  *
15
15
  * The same dir also holds the per-splice SPAN files (`span-<key>.txt`): each
16
16
  * Layer-2 splice's (redacted) original, keyed by its placeholder's
@@ -25,17 +25,26 @@ import {
25
25
  mkdirSync,
26
26
  lstatSync,
27
27
  readdirSync,
28
+ rmSync,
28
29
  unlinkSync,
29
30
  openSync,
30
31
  readFileSync,
31
32
  closeSync,
32
33
  constants,
33
34
  } from "node:fs";
34
- import { tmpdir } from "node:os";
35
35
  import { join, resolve, sep } from "node:path";
36
- import { PROJECT_HASH, isPrivateDir, writeFileNoFollow } from "./hook-io.mjs";
36
+ import {
37
+ PROJECT_HASH,
38
+ STATE_ROOT,
39
+ privateDirAt,
40
+ writeFileNoFollow,
41
+ } from "./hook-io.mjs";
37
42
  import { readKnob } from "./knobs.mjs";
38
43
 
44
+ /** The default reveal store's name, less the project hash that completes it. */
45
+ const REVEAL_STORE_STEM = "agent-sanitizer-layer2-reveal-";
46
+ const ANY_PROJECT_HASH = /^[0-9a-f]{8}$/u;
47
+
39
48
  /**
40
49
  * Where reveal sidecars are stored. Exported so the PreToolUse placeholder
41
50
  * advisory can name the directory a spliced original was saved under without
@@ -43,14 +52,13 @@ import { readKnob } from "./knobs.mjs";
43
52
  * @returns {string}
44
53
  */
45
54
  export function revealDir() {
46
- // Project-keyed like every other $TMPDIR store: one unkeyed directory is shared
47
- // by every project on the machine, so one project's sweep ages out another's
48
- // spans and a rehydration that should have restored a placeholder fails closed
49
- // instead. The 12-hex span KEY inside the directory is untouched — it is the
50
- // published placeholder grammar, not a path detail.
55
+ // Under STATE_ROOT, so an OS temp cleaner cannot empty it under a live session.
56
+ // Project-keyed, so one project's sweep cannot age out another's spans — either
57
+ // loss fails a rehydration closed. The 12-hex span KEY inside is untouched: it
58
+ // is the published placeholder grammar, not a path detail.
51
59
  return (
52
60
  readKnob("_AGENT_SANITIZER_REVEAL_DIR") ??
53
- join(tmpdir(), `agent-sanitizer-layer2-reveal-${PROJECT_HASH}`)
61
+ join(STATE_ROOT, `${REVEAL_STORE_STEM}${PROJECT_HASH}`)
54
62
  );
55
63
  }
56
64
 
@@ -63,7 +71,8 @@ export function revealDir() {
63
71
  export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
64
72
 
65
73
  /**
66
- * Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}.
74
+ * Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS},
75
+ * and every other project's default store that holds nothing younger.
67
76
  *
68
77
  * Nothing else removes them: every entry is content-addressed, so a store that is
69
78
  * never swept grows for the life of the machine. Called from SessionStart, the one
@@ -71,6 +80,7 @@ export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
71
80
  * @returns {void}
72
81
  */
73
82
  export function sweepStaleReveals() {
83
+ sweepAbandonedRevealStores();
74
84
  const dir = revealDir();
75
85
  // existsSync first: revealDirIsSafe CREATES the directory, and a sweep run on
76
86
  // every session start must not leave an empty store behind for a project that
@@ -93,6 +103,37 @@ export function sweepStaleReveals() {
93
103
  }
94
104
  }
95
105
 
106
+ /**
107
+ * Remove OTHER projects' default reveal stores under STATE_ROOT once they are
108
+ * older than {@link REVEAL_TTL_MS}. Each project sweeps only its own store's
109
+ * files, and STATE_ROOT is persistent when it is the plugin data dir, so a
110
+ * project never reopened would keep its spans — tool output — for good. A
111
+ * store's mtime moves with every entry written or swept, and entries are
112
+ * write-once, so a store past the cutoff holds only files past it too.
113
+ * @returns {void}
114
+ */
115
+ function sweepAbandonedRevealStores() {
116
+ const cutoff = Date.now() - REVEAL_TTL_MS;
117
+ for (const name of readdirSync(STATE_ROOT)) {
118
+ const path = join(STATE_ROOT, name);
119
+ const isStore =
120
+ name.startsWith(REVEAL_STORE_STEM) &&
121
+ ANY_PROJECT_HASH.test(name.slice(REVEAL_STORE_STEM.length));
122
+ if (!isStore || path === revealDir()) continue;
123
+ // privateDirAt: a co-tenant's store, or a symlink planted at the name, is
124
+ // not this uid's to remove.
125
+ if (!privateDirAt(path)) continue;
126
+ try {
127
+ if (lstatSync(path).mtimeMs < cutoff)
128
+ rmSync(path, { recursive: true, force: true });
129
+ } catch (err) {
130
+ // ENOENT: a parallel session swept this store between readdir and lstat.
131
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT")
132
+ throw err;
133
+ }
134
+ }
135
+ }
136
+
96
137
  /**
97
138
  * Content-addressed path the pre-splice text of `content` is stored at.
98
139
  * @param {string} content
@@ -120,19 +161,7 @@ function revealDirIsSafe(dir) {
120
161
  } catch {
121
162
  return false;
122
163
  }
123
- let st;
124
- try {
125
- st = lstatSync(dir);
126
- /* c8 ignore start -- TOCTOU-race defense: after the mkdirSync above succeeds, dir
127
- exists with accessible parents, so lstatSync can only throw if a co-tenant removes
128
- or replaces it in the window between the two syscalls — unreachable in a
129
- single-threaded test, yet load-bearing to keep persistReveal fail-closed on that
130
- race rather than crashing. */
131
- } catch {
132
- return false;
133
- }
134
- /* c8 ignore stop */
135
- return isPrivateDir(st);
164
+ return privateDirAt(dir);
136
165
  }
137
166
 
138
167
  /**
@@ -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
@@ -495,10 +495,10 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
495
495
  // sessions left instead — SessionStart is the once-per-session touchpoint the
496
496
  // sweep belongs on.
497
497
  sweepStaleSessions(sessionId);
498
- // The other two $TMPDIR stores these hooks own. Both are content- or
499
- // fingerprint-addressed with no natural owner to delete them, so without a
500
- // sweep they grow for the life of the machine; SessionStart is the only
501
- // touchpoint that runs once per session rather than once per tool call.
498
+ // The other two stores these hooks own (reveal under STATE_ROOT, confirm in
499
+ // $TMPDIR). Both are content- or fingerprint-addressed with no natural owner
500
+ // to delete them, so without a sweep they grow for the life of the machine;
501
+ // SessionStart is the only touchpoint that runs once per session.
502
502
  sweepStaleReveals();
503
503
  sweepStaleConfirms();
504
504
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.66.3",
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": {
@@ -294,6 +294,27 @@ export function safeErrMessage(err: unknown, cap?: number): string;
294
294
  * @returns {void}
295
295
  */
296
296
  export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
297
+ /**
298
+ * The directory the session-lifetime stores live under: the alert markers and
299
+ * findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
300
+ * outlive the longest session, which $TMPDIR does not promise — macOS's daily
301
+ * dirhelper job deletes anything in the per-user `/var/folders/.../T/` not
302
+ * accessed for 3 days, and a marker written at launch and only ever `lstat`ed
303
+ * then vanishes from a session still running, its absence read as "never ran".
304
+ *
305
+ * `$CLAUDE_PLUGIN_DATA/session-state` when both levels are private to this uid
306
+ * ({@link privateDirAt}): write on the plugin data dir alone is enough to swap
307
+ * `session-state` out. Each level is created 0700 when absent — so the
308
+ * launch-time InstructionsLoaded event and the first tool call resolve the same
309
+ * root whichever runs first — and checked BEFORE the next is created inside it,
310
+ * so a symlinked or shared plugin data dir never gets a directory planted in
311
+ * its target. A root this process cannot write — a read-only mount, an owner
312
+ * bit removed — is no root: every marker write would fail and read as "the
313
+ * event never fired". Otherwise $TMPDIR, where the stores' squat checks apply.
314
+ * @param {Record<string, string | undefined>} env
315
+ * @returns {string}
316
+ */
317
+ export function resolveStateRoot(env: Record<string, string | undefined>): string;
297
318
  /**
298
319
  * Adopt a host's own cold-start marker path in place of the derived one, so a
299
320
  * host whose setup script already writes a marker under its own convention can
@@ -441,7 +462,7 @@ export function markerIsTrusted(path: string | null): boolean;
441
462
  export function currentUid(): number | null;
442
463
  /**
443
464
  * Whether `st` (an lstat result) is owned by this process's uid. THE ownership
444
- * predicate for every $TMPDIR store these hooks trust — a second spelling is how
465
+ * predicate for every state store these hooks trust — a second spelling is how
445
466
  * four call sites came to share the passwd-lookup crash {@link currentUid}
446
467
  * documents. False when the platform has no uid model: ownership that cannot be
447
468
  * proven is not granted. `uid` is a seam for a test that must model another
@@ -457,10 +478,11 @@ export function ownedByCurrentUser(st: {
457
478
  * Whether `st` (an lstat result) is a directory no untrusted uid can change: a
458
479
  * real directory (lstat, so a planted symlink is not one), owned by this uid —
459
480
  * or by root where `rootMayOwn` — with no group/other write bit. THE trust test
460
- * for every private directory these hooks keep in $TMPDIR. Write is the
461
- * property because creating, unlinking or replacing an entry needs write on its
462
- * parent: a co-tenant holding it can delete the finding a gate needs, swap a
463
- * reveal file, or rebind the redactor socket to a listener that redacts nothing.
481
+ * for every private directory these hooks keep, under STATE_ROOT or $TMPDIR.
482
+ * Write is the property because creating, unlinking or replacing an entry needs
483
+ * write on its parent: a co-tenant holding it can delete the finding a gate
484
+ * needs, swap a reveal file, or rebind the redactor socket to a listener that
485
+ * redacts nothing.
464
486
  * Root ownership is strictly stronger than ours, so a deployment may hand a dir
465
487
  * to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
466
488
  * @param {{ isDirectory(): boolean, uid: number, mode: number }} st
@@ -476,6 +498,29 @@ export function isPrivateDir(st: {
476
498
  uid?: number | null;
477
499
  rootMayOwn?: boolean;
478
500
  }): boolean;
501
+ /**
502
+ * Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
503
+ * the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted at
504
+ * a predictable path is judged on ITSELF: followed, it would let a co-tenant aim
505
+ * a reader at unrelated files this uid owns (the alert gate splices those bytes
506
+ * into a permission prompt). An absent path is simply not one.
507
+ * @param {string} path
508
+ * @param {{ uid?: number | null }} [opts] as in {@link isPrivateDir}
509
+ * @returns {boolean}
510
+ */
511
+ export function privateDirAt(path: string, opts?: {
512
+ uid?: number | null;
513
+ }): boolean;
514
+ /**
515
+ * Stamp a state file the caller has just trusted as used now, so a live
516
+ * session's state never looks idle to an age-based sweep — another session's
517
+ * {@link STATE_ROOT} sweep (mtime) or an OS temp cleaner (atime). `lutimes`, so
518
+ * a symlink swapped in after the caller's check is stamped itself, never its
519
+ * target. A path a parallel sweep removed after that check is simply gone.
520
+ * @param {string} path
521
+ * @returns {void}
522
+ */
523
+ export function touchLiveState(path: string): void;
479
524
  /**
480
525
  * Create a presence sentinel at `path` without following a symlink a co-tenant
481
526
  * may have pre-planted there. These sentinels live at predictable, world-visible
@@ -585,6 +630,8 @@ export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (p
585
630
  export const PROJECT_DIR: string;
586
631
  /** Short project digest keying this project's $TMPDIR store names. */
587
632
  export const PROJECT_HASH: string;
633
+ /** {@link resolveStateRoot} for this process, resolved once at load. */
634
+ export const STATE_ROOT: string;
588
635
  /**
589
636
  * The line a cold-start marker carries on its own to declare that its writer holds
590
637
  * an exclusive `flock` on the marker file for the whole install.
@@ -46,16 +46,21 @@ export function instructionsLoadedNoticeFile(sessionId?: string): string;
46
46
  * lazily-loaded instruction files are being scanned at all. Ownership-validated
47
47
  * like every other marker here: a co-tenant could otherwise plant the
48
48
  * predictable path and suppress the notice below, which is the whole signal that
49
- * nested files are going unscanned.
49
+ * nested files are going unscanned. A marker that is seen is stamped as used:
50
+ * it is written once at launch, so otherwise a session outliving
51
+ * MARKER_TTL_MS would have it swept by another session's SessionStart and read
52
+ * its absence as the event never having fired.
50
53
  * @param {string} [sessionId]
51
54
  * @returns {boolean}
52
55
  */
53
56
  export function instructionsLoadedSeen(sessionId?: string): boolean;
54
57
  /**
55
- * Delete this project's artifacts from OTHER sessions once they are older than
58
+ * Delete OTHER sessions' artifacts, of every project, once they are older than
56
59
  * the TTL. The current session's own prefix is skipped, so the sweep can never
57
60
  * answer its own question wrong; every other session's files are past history
58
- * that nothing reads.
61
+ * that nothing reads — a live one keeps its marker and findings fresh on read.
62
+ * Every project, because STATE_ROOT may be persistent: a project never reopened
63
+ * would otherwise keep its artifacts for good.
59
64
  *
60
65
  * This replaces the destructive SessionStart clear: with the store session-keyed
61
66
  * there is nothing to reset, only old state to age out.
@@ -65,7 +70,7 @@ export function instructionsLoadedSeen(sessionId?: string): boolean;
65
70
  export function sweepStaleSessions(sessionId?: string): void;
66
71
  /**
67
72
  * Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
68
- * write (see writeSentinelFile) at a predictable $TMPDIR path.
73
+ * write (see writeSentinelFile) at a predictable STATE_ROOT path.
69
74
  *
70
75
  * The event fires once per instruction file loaded, so the already-recorded case
71
76
  * returns without a write — and the stale-session sweep rides the FIRST fire of
@@ -77,9 +82,7 @@ export function recordInstructionsLoaded(sessionId?: string): void;
77
82
  /**
78
83
  * Companion marker: this session already found the LAUNCH set empty — that set
79
84
  * (which files load at session start) cannot change mid-session, so a second
80
- * glob of `dir` can never change that half of the answer. It says nothing about
81
- * a directory touched later; {@link instructionsLoadedGapNotice}'s `touchedDir`
82
- * covers that half fresh on every call instead.
85
+ * glob of `dir` can never change the answer.
83
86
  * @param {string} [sessionId]
84
87
  * @returns {string}
85
88
  */
@@ -100,30 +103,32 @@ export function launchInstructionFiles(dir: string): string[];
100
103
  /**
101
104
  * The one-time context line for a session where no InstructionsLoaded scan ran,
102
105
  * or null when the scan has been seen, the notice already ran this session, the
103
- * host's cold-start marker says setup is still running, or neither launch nor
104
- * `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.
105
108
  *
106
109
  * PURE for the notice: nothing is recorded until the caller confirms it landed
107
- * in a returned response. Launch-emptiness is cached once found — fixed for the
108
- * session — but `touchedDir` is re-checked every call: the claim below is about
109
- * SUBDIRECTORY files, so an empty launch must not silence a later call whose
110
- * 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.
111
112
  *
112
- * SessionStart scans what loads at launch; a subdirectory's file is scanned by
113
- * the event; no scan means nothing says so — unless nothing touched so far held
114
- * a file the event would have ANNOUNCED, with bytes in it. The three remaining
115
- * causes are named together, since
116
- * the marker cannot tell them apart: an unwired event, a Claude Code older than
117
- * 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.
118
117
  * @param {string} [sessionId] the harness's session identity (see
119
118
  * instructionsLoadedFile)
120
119
  * @param {string} [dir] the project root to check (injectable; defaults to
121
120
  * the real project)
122
- * @param {string} [touchedDir] this tool call's own target directory, when
123
- * 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
124
126
  * @returns {string | null}
125
127
  */
126
- 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;
127
132
  /**
128
133
  * Record that the gap notice above was surfaced, so it rides on ONE tool call
129
134
  * rather than every one — the per-call repeat is what trains a reader to skip it.
@@ -136,14 +141,14 @@ export function recordInstructionsLoadedNotice(sessionId?: string): void;
136
141
  * The alert findings if invisible-char injection was detected in instruction
137
142
  * files and couldn't be auto-cleaned, else null.
138
143
  *
139
- * The store is a DIRECTORY of one file per finding, all at predictable,
140
- * world-visible $TMPDIR paths, so both the directory and every entry in it are
141
- * attacker-plantable: trust the directory only when it is a real directory this
142
- * uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
143
- * each entry only when markerIsTrusted confirms a regular file this uid owns,
144
- * then scrub the bytes through Layer-1 before any caller splices them into a
145
- * reason — the report would otherwise carry ANSI/invisible spoofing into the
146
- * model's context.
144
+ * The store is a DIRECTORY of one file per finding, all at predictable
145
+ * STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
146
+ * directory and every entry in it are attacker-plantable: trust the directory
147
+ * only when it is a real directory this uid owns (a symlink would let a
148
+ * co-tenant aim this reader at unrelated files), each entry only when
149
+ * markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
150
+ * through Layer-1 before any caller splices them into a reason — the report
151
+ * would otherwise carry ANSI/invisible spoofing into the model's context.
147
152
  * This session's store AND the shared `no-session` fallback, because a hook that
148
153
  * faults BEFORE it can parse its payload has no session identity to key by: its
149
154
  * finding lands in the fallback, and a strictly session-keyed read would leave
@@ -163,7 +168,7 @@ export function invisibleCharAlert(sessionId?: string): string | null;
163
168
  * through a read-modify-write would let two hooks recording a finding at once
164
169
  * silently drop one of them; a fresh file per finding has no shared cell to
165
170
  * lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
166
- * predictable, world-visible $TMPDIR path.
171
+ * predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
167
172
  * @param {string} text
168
173
  * @param {string} [sessionId]
169
174
  * @returns {boolean} whether the finding was recorded
@@ -172,9 +177,10 @@ export function appendAlert(text: string, sessionId?: string): boolean;
172
177
  /**
173
178
  * True once the gate has surfaced its blocking ask this session. Validates
174
179
  * ownership (not mere existence): a co-tenant could pre-create the ack at its
175
- * predictable $TMPDIR path to permanently suppress the one-time blocking ask down
176
- * to the passive reminder, so trust the marker only when it is a regular file
177
- * this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
180
+ * predictable STATE_ROOT path to permanently suppress the one-time blocking ask
181
+ * down to the passive reminder, so trust the marker only when it is a regular
182
+ * file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
183
+ * it.
178
184
  *
179
185
  * On a host that exports no session id every session shares the fallback prefix,
180
186
  * so the ack has no session to end with and would suppress the one-time blocking
@@ -211,6 +217,9 @@ export function gateReminderContext(): string;
211
217
  /**
212
218
  * The path prefix every alert artifact of this PROJECT shares. Never a file
213
219
  * itself — only {@link sessionPrefix} and the sweep read it — so that one
214
- * `startsWith` covers every artifact the sweep must age out.
220
+ * `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
221
+ * not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
222
+ * read as the InstructionsLoaded scan never having run, and deleting its
223
+ * findings would lift the gate mid-session.
215
224
  */
216
225
  export const ALERT_BASE: string;
@@ -6,7 +6,7 @@
6
6
  * 1. `_AGENT_SANITIZER_REDACTOR_SOCKET` — explicit override.
7
7
  * 2. `$XDG_RUNTIME_DIR`, then `$CLAUDE_PLUGIN_DATA` — per-user directories no
8
8
  * other uid can write, so a co-tenant cannot pre-create the socket dir under
9
- * them. Taken only if isPrivateRoot passes and the path fits sun_path.
9
+ * them. Taken only if privateDirAt passes and the path fits sun_path.
10
10
  * 3. `<tmpdir>/agent-sanitizer-redactor-<uid>` — on a shared /tmp a co-tenant
11
11
  * can still pre-create it; the daemon then refuses to serve and the client
12
12
  * refuses to connect (classifySocket), costing availability, never a secret.
@@ -6,7 +6,8 @@
6
6
  */
7
7
  export function revealDir(): string;
8
8
  /**
9
- * Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}.
9
+ * Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS},
10
+ * and every other project's default store that holds nothing younger.
10
11
  *
11
12
  * Nothing else removes them: every entry is content-addressed, so a store that is
12
13
  * never swept grows for the life of the machine. Called from SessionStart, the one