agent-sanitizer 2.66.2 → 2.66.4

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
@@ -797,7 +797,7 @@ positive costs a sentence of context, never a mangled input):
797
797
  the identical Write confirms and passes. The confirmation is the model's
798
798
  deliberate retry — never a human permission prompt — held as a
799
799
  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
800
+ values) in `$TMPDIR` via the same squat-resistant helpers as the invisible-char
801
801
  gate. Tracked files, secret-free files, failed git probes and unmappable
802
802
  views all skip the guard (fail open). "Tracked" approximates "recoverable":
803
803
  an uncommitted secret line on a tracked file is an accepted gap — the
@@ -927,6 +927,16 @@ it, so what anchors its trust is worth stating exactly.
927
927
  under `TMPDIR` is read only when this user owns it with owner-only write, so a
928
928
  squatted shared-temp entry cannot pre-suppress the warning that a call ran
929
929
  unsanitized.
930
+ - **Session state lives under that directory too, not in `$TMPDIR`.** The
931
+ InstructionsLoaded marker, the invisible-char gate's findings and ack, and the
932
+ Layer-2 reveal spans sit in `${CLAUDE_PLUGIN_DATA}/session-state`, created mode
933
+ 700 and used only when both it and the data directory are real directories
934
+ this user owns with no group/other write (`privateDirAt`) and the leaf is
935
+ writable. An OS temp cleaner (macOS dirhelper purges `$TMPDIR` entries unread
936
+ for 3 days) would otherwise delete a live session's marker — a false "no
937
+ InstructionsLoaded scan has run" notice — or its findings, lifting the gate.
938
+ A host with no plugin data directory, or one that fails those checks, falls
939
+ back to `$TMPDIR` and keeps that exposure.
930
940
  - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
931
941
  a binary. That leaves the node path running the shipped bundle and the
932
942
  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
@@ -21,16 +21,19 @@ import {
21
21
  } from "node:fs";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { basename, join } from "node:path";
24
- import { homedir, tmpdir } from "node:os";
24
+ import { homedir } from "node:os";
25
25
  import {
26
26
  hookgateMarkerPath,
27
27
  lazyImport,
28
28
  markerIsTrusted,
29
- isPrivateDir,
29
+ ownedByCurrentUser,
30
+ privateDirAt,
30
31
  probeSetupAlive,
31
32
  PROJECT_DIR,
32
33
  PROJECT_HASH,
33
34
  scrubUntrustedText,
35
+ STATE_ROOT,
36
+ touchLiveState,
34
37
  writeFileNoFollow,
35
38
  writeSentinelFile,
36
39
  } from "./hook-io.mjs";
@@ -59,15 +62,28 @@ const { applyLayer1WellFormed } =
59
62
  await lazyImport("agent-sanitizer")
60
63
  );
61
64
 
65
+ /** Every alert artifact name's start, less the project hash that follows it. */
66
+ const ALERT_STEM = ".claude-invisible-char-alert-";
67
+
62
68
  /**
63
69
  * The path prefix every alert artifact of this PROJECT shares. Never a file
64
70
  * itself — only {@link sessionPrefix} and the sweep read it — so that one
65
- * `startsWith` covers every artifact the sweep must age out.
71
+ * `startsWith` covers every artifact the sweep must age out. Under STATE_ROOT,
72
+ * not $TMPDIR itself: an OS temp cleaner deleting a live session's marker would
73
+ * read as the InstructionsLoaded scan never having run, and deleting its
74
+ * findings would lift the gate mid-session.
75
+ */
76
+ export const ALERT_BASE = join(STATE_ROOT, `${ALERT_STEM}${PROJECT_HASH}`);
77
+
78
+ /**
79
+ * Whether `name` is a session artifact of ANY project: {@link ALERT_BASE}'s
80
+ * name, whatever its project hash, then `.s-`.
81
+ * @param {string} name
82
+ * @returns {boolean}
66
83
  */
67
- export const ALERT_BASE = join(
68
- tmpdir(),
69
- `.claude-invisible-char-alert-${PROJECT_HASH}`,
70
- );
84
+ const isAnyProjectSessionEntry = (name) =>
85
+ name.startsWith(ALERT_STEM) &&
86
+ /^[0-9a-f]{8}\.s-/u.test(name.slice(ALERT_STEM.length));
71
87
 
72
88
  /**
73
89
  * The path prefix every alert artifact of ONE session under this project shares.
@@ -86,7 +102,7 @@ export const ALERT_BASE = join(
86
102
  export function sessionPrefix(sessionId) {
87
103
  // The id becomes a path component, so anything outside this class — a `/` in
88
104
  // 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,
105
+ // STATE_ROOT. A host that exports no session id falls back to one shared name,
90
106
  // whose findings and ack are bounded by FALLBACK_TTL_MS instead.
91
107
  const key =
92
108
  (sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
@@ -138,12 +154,20 @@ export function instructionsLoadedNoticeFile(sessionId) {
138
154
  * lazily-loaded instruction files are being scanned at all. Ownership-validated
139
155
  * like every other marker here: a co-tenant could otherwise plant the
140
156
  * predictable path and suppress the notice below, which is the whole signal that
141
- * nested files are going unscanned.
157
+ * nested files are going unscanned. A marker that is seen is stamped as used:
158
+ * it is written once at launch, so otherwise a session outliving
159
+ * MARKER_TTL_MS would have it swept by another session's SessionStart and read
160
+ * its absence as the event never having fired.
142
161
  * @param {string} [sessionId]
143
162
  * @returns {boolean}
144
163
  */
145
164
  export function instructionsLoadedSeen(sessionId) {
146
- return markerIsTrusted(instructionsLoadedFile(sessionId));
165
+ const marker = instructionsLoadedFile(sessionId);
166
+ if (!markerIsTrusted(marker)) return false;
167
+ // Not the shared no-session marker: every session-less host would refresh
168
+ // it, and MARKER_TTL_MS is what keeps it from silencing every later session.
169
+ if (sessionPrefix(sessionId) !== sessionPrefix()) touchLiveState(marker);
170
+ return true;
147
171
  }
148
172
 
149
173
  /** How long a past session's artifacts are kept before a later session sweeps. */
@@ -182,8 +206,8 @@ const FALLBACK_TTL_MS = 30 * 60 * 1000;
182
206
  * this `lstat` reads as expired, which is what every caller wants of a file that
183
207
  * is no longer there: the sweep's unlink becomes a no-op, the gate's reader skips
184
208
  * 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.
209
+ * would abort recordInstructionsLoaded on a benign STATE_ROOT race and render
210
+ * the false "instruction file was NOT scanned" fault.
187
211
  *
188
212
  * `lstat`, so a planted symlink is judged on itself.
189
213
  * @param {string} path
@@ -206,30 +230,12 @@ function withinTtl(path, ttlMs) {
206
230
  const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
207
231
 
208
232
  /**
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
233
+ * Delete OTHER sessions' artifacts, of every project, once they are older than
230
234
  * the TTL. The current session's own prefix is skipped, so the sweep can never
231
235
  * answer its own question wrong; every other session's files are past history
232
- * that nothing reads.
236
+ * that nothing reads — a live one keeps its marker and findings fresh on read.
237
+ * Every project, because STATE_ROOT may be persistent: a project never reopened
238
+ * would otherwise keep its artifacts for good.
233
239
  *
234
240
  * This replaces the destructive SessionStart clear: with the store session-keyed
235
241
  * there is nothing to reset, only old state to age out.
@@ -237,28 +243,28 @@ function dirIsTrusted(path) {
237
243
  * @returns {void}
238
244
  */
239
245
  export function sweepStaleSessions(sessionId) {
240
- const dir = tmpdir();
241
- const prefix = `${basename(ALERT_BASE)}.s-`;
246
+ const dir = STATE_ROOT;
242
247
  const keep = basename(sessionPrefix(sessionId));
243
248
  const cutoff = Date.now() - MARKER_TTL_MS;
244
249
  for (const name of readdirSync(dir)) {
245
- if (!name.startsWith(prefix)) continue;
250
+ if (!isAnyProjectSessionEntry(name)) continue;
246
251
  // Whole-segment match on the keep prefix, not a bare startsWith: session key
247
252
  // "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
248
253
  if (name === keep || name.startsWith(`${keep}.`)) continue;
249
254
  const path = join(dir, name);
250
255
  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;
256
+ // lstat, not stat: a squatted symlink at a predictable path is judged on
257
+ // ITSELF. Ownership first: in a shared /tmp another uid's entry is not
258
+ // ours to remove, and an rmSync on its file fails ENOTDIR, not EPERM.
259
+ const st = lstatSync(path);
260
+ if (!ownedByCurrentUser(st) || st.mtimeMs >= cutoff) continue;
254
261
  rmSync(path, { recursive: true, force: true });
255
262
  } catch (err) {
256
263
  // 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.
264
+ // EPERM/EACCES: an entry of ours a co-tenant's directory shields. A throw
265
+ // here would abort recordInstructionsLoaded and render the "instruction
266
+ // file was NOT scanned" fault on a session that WAS scanned. Anything else
267
+ // is a bug in this sweep and propagates.
262
268
  const code = /** @type {NodeJS.ErrnoException} */ (err).code;
263
269
  if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
264
270
  }
@@ -268,7 +274,7 @@ export function sweepStaleSessions(sessionId) {
268
274
 
269
275
  /**
270
276
  * 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
277
+ * stop occupying STATE_ROOT too. The loop above cannot: it skips the current
272
278
  * session's prefix, which on a host with no session id IS this one.
273
279
  *
274
280
  * Mirrors what {@link invisibleCharAlert} and {@link alertAcknowledged} read.
@@ -281,14 +287,14 @@ export function sweepStaleSessions(sessionId) {
281
287
  * scanned, or re-glob a launch this session already found empty. They still go,
282
288
  * at MARKER_TTL_MS — the same age the loop above ages a real session's prefix
283
289
  * 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.
290
+ * and would otherwise leave a session-less host's markers in STATE_ROOT
291
+ * forever, with the gap notice suppressed on every later session.
286
292
  * @param {string} [sessionId]
287
293
  * @returns {void}
288
294
  */
289
295
  function sweepStaleFallback(sessionId) {
290
296
  const dir = alertDir();
291
- const inherited = alertDir(sessionId) !== dir && dirIsTrusted(dir);
297
+ const inherited = alertDir(sessionId) !== dir && privateDirAt(dir);
292
298
  const entries = inherited
293
299
  ? readdirSync(dir).map((name) => join(dir, name))
294
300
  : [];
@@ -311,7 +317,7 @@ function sweepStaleFallback(sessionId) {
311
317
 
312
318
  /**
313
319
  * Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
314
- * write (see writeSentinelFile) at a predictable $TMPDIR path.
320
+ * write (see writeSentinelFile) at a predictable STATE_ROOT path.
315
321
  *
316
322
  * The event fires once per instruction file loaded, so the already-recorded case
317
323
  * returns without a write — and the stale-session sweep rides the FIRST fire of
@@ -534,14 +540,14 @@ export function recordInstructionsLoadedNotice(sessionId) {
534
540
  * The alert findings if invisible-char injection was detected in instruction
535
541
  * files and couldn't be auto-cleaned, else null.
536
542
  *
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.
543
+ * The store is a DIRECTORY of one file per finding, all at predictable
544
+ * STATE_ROOT paths (world-visible in the $TMPDIR fallback), so both the
545
+ * directory and every entry in it are attacker-plantable: trust the directory
546
+ * only when it is a real directory this uid owns (a symlink would let a
547
+ * co-tenant aim this reader at unrelated files), each entry only when
548
+ * markerIsTrusted confirms a regular file this uid owns, then scrub the bytes
549
+ * through Layer-1 before any caller splices them into a reason — the report
550
+ * would otherwise carry ANSI/invisible spoofing into the model's context.
545
551
  * This session's store AND the shared `no-session` fallback, because a hook that
546
552
  * faults BEFORE it can parse its payload has no session identity to key by: its
547
553
  * finding lands in the fallback, and a strictly session-keyed read would leave
@@ -560,7 +566,10 @@ export function invisibleCharAlert(sessionId) {
560
566
  const dirs = own === alertDir() ? [own] : [own, alertDir()];
561
567
  const parts = [];
562
568
  for (const dir of dirs) {
563
- if (!dirIsTrusted(dir)) continue;
569
+ if (!privateDirAt(dir)) continue;
570
+ // A live session's findings must outlast any sweep that ages the store by
571
+ // its directory's timestamp: losing them lifts the gate mid-session.
572
+ if (dir === own) touchLiveState(dir);
564
573
  // Sorted so a multi-finding report reads the same on every call; the names
565
574
  // are random, so the order carries no meaning beyond being stable.
566
575
  for (const name of readdirSync(dir).sort()) {
@@ -583,7 +592,7 @@ export function invisibleCharAlert(sessionId) {
583
592
  * through a read-modify-write would let two hooks recording a finding at once
584
593
  * silently drop one of them; a fresh file per finding has no shared cell to
585
594
  * lose. Symlink-refusing (writeFileNoFollow) because the store sits at a
586
- * predictable, world-visible $TMPDIR path.
595
+ * predictable STATE_ROOT path, world-visible in the $TMPDIR fallback.
587
596
  * @param {string} text
588
597
  * @param {string} [sessionId]
589
598
  * @returns {boolean} whether the finding was recorded
@@ -597,7 +606,7 @@ export function appendAlert(text, sessionId) {
597
606
  let usable;
598
607
  try {
599
608
  mkdirSync(dir, { recursive: true, mode: 0o700 });
600
- usable = dirIsTrusted(dir);
609
+ usable = privateDirAt(dir);
601
610
  } catch {
602
611
  usable = false;
603
612
  }
@@ -612,9 +621,10 @@ export function appendAlert(text, sessionId) {
612
621
  /**
613
622
  * True once the gate has surfaced its blocking ask this session. Validates
614
623
  * 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.
624
+ * predictable STATE_ROOT path to permanently suppress the one-time blocking ask
625
+ * down to the passive reminder, so trust the marker only when it is a regular
626
+ * file this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes
627
+ * it.
618
628
  *
619
629
  * On a host that exports no session id every session shares the fallback prefix,
620
630
  * so the ack has no session to end with and would suppress the one-time blocking
@@ -638,8 +648,8 @@ export function alertAcknowledged(sessionId) {
638
648
  * @returns {void}
639
649
  */
640
650
  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).
651
+ // Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
652
+ // a co-tenant could pre-plant a symlink at (see writeSentinelFile).
643
653
  writeSentinelFile(alertAckFile(sessionId));
644
654
  }
645
655
 
@@ -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
  /**
@@ -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.2",
3
+ "version": "2.66.4",
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
@@ -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