agent-sanitizer 2.43.12 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -368,6 +368,14 @@ hook module, and every consumer waits on that path instead. `lib/control-plane`
368
368
  resolves the marker at module scope, so a call that lands after that import
369
369
  warns on stderr — it cannot steer the wait that already started.
370
370
 
371
+ The marker's first line is the setup process's pid. A writer that also holds an
372
+ exclusive `flock` on the marker file for the whole install says so by adding a
373
+ second line reading `flock` (`SETUP_LOCK_DECLARATION`), and the hooks then judge
374
+ liveness by the lock rather than the pid: the kernel releases an `flock` the
375
+ instant its holder dies, so a killed setup is detected immediately instead of
376
+ being read as alive for as long as a recycled pid keeps answering. A marker that
377
+ declares nothing is judged by its pid, as before.
378
+
371
379
  **A host's own remedy can replace the packaged one in every failure reason**
372
380
  (the fail-closed verdicts and the fail-open warning alike). Deep call sites (`lib/control-plane`'s missing-package throw) take no
373
381
  remedy argument, so by default they can only say `pnpm install`. A host whose
@@ -8,6 +8,7 @@ import {
8
8
  unlinkSync,
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
+ import { spawnSync } from "node:child_process";
11
12
  import { userInfo } from "node:os";
12
13
  import { createHash } from "node:crypto";
13
14
  import { pathToFileURL } from "node:url";
@@ -692,6 +693,19 @@ export function emitHookResponse(hookEventName, fields) {
692
693
  );
693
694
  }
694
695
 
696
+ /**
697
+ * The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
698
+ * it, so it lives here — beside the other shared identity these hooks agree on —
699
+ * rather than in whichever store happened to need it first.
700
+ */
701
+ export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
702
+
703
+ /** Short project digest keying this project's $TMPDIR store names. */
704
+ export const PROJECT_HASH = createHash("sha256")
705
+ .update(PROJECT_DIR)
706
+ .digest("hex")
707
+ .slice(0, 8);
708
+
695
709
  /** The marker filename stem; the project directory is appended to it. */
696
710
  const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
697
711
 
@@ -765,24 +779,70 @@ export function hookgateMarkerPath(
765
779
  }
766
780
 
767
781
  /**
768
- * Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
769
- * probes liveness without signalling: it throws ESRCH once the process is gone (a
770
- * killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
771
- * ours (still alive). An unreadable / not-yet-written marker is treated as alive
772
- * favouring a brief wait over a premature give-up during setup's write race. A null
773
- * markerPath (no project dir no setup to wait on) reads as alive so the caller's
774
- * own grace/ceiling bound governs.
782
+ * The line a cold-start marker carries on its own to declare that its writer holds
783
+ * an exclusive `flock` on the marker file for the whole install.
784
+ *
785
+ * This is the marker's PROTOCOL, and the reason it is declared in the data rather
786
+ * than assumed: a reader cannot tell "the writer released the lock because it died"
787
+ * from "the writer never took one" by looking at a free lock, so only a marker that
788
+ * says it locks may be judged by the lock.
789
+ */
790
+ export const SETUP_LOCK_DECLARATION = "flock";
791
+
792
+ /**
793
+ * Whether an exclusive lock is currently held on `markerPath`, or null when the
794
+ * question cannot be answered here (no `flock(1)` — it is util-linux, absent from a
795
+ * stock macOS — or an exit status neither "acquired" nor "busy").
796
+ * @param {string} markerPath
797
+ * @returns {boolean | null}
798
+ */
799
+ function markerLockHeld(markerPath) {
800
+ const probe = spawnSync(
801
+ "flock",
802
+ ["--nonblock", "--exclusive", markerPath, "true"],
803
+ { stdio: "ignore" },
804
+ );
805
+ if (probe.error) return null;
806
+ if (probe.status === 0) return false; // acquired: nobody holds it
807
+ if (probe.status === 1) return true; // busy: the writer still holds it
808
+ return null;
809
+ }
810
+
811
+ /**
812
+ * Is the setup process that wrote `markerPath` still alive?
813
+ *
814
+ * A marker declaring {@link SETUP_LOCK_DECLARATION} is judged by the LOCK, and that
815
+ * answer has no aliasing: the kernel drops an flock the instant its holder dies, so
816
+ * held means alive and free means dead, with no third state and nothing to reuse. A
817
+ * marker that declares nothing carries only a pid, and `process.kill(pid, 0)` is all
818
+ * there is — it throws ESRCH once the process is gone (a killed setup → stale
819
+ * marker, so stop waiting) and EPERM when it exists but is not ours (still alive).
820
+ * That reading is the one this replaces where it can: a recycled pid reads as a live
821
+ * setup for as long as the caller's ceiling allows.
822
+ *
823
+ * An unreadable / not-yet-written marker is treated as alive — favouring a brief
824
+ * wait over a premature give-up during setup's write race. A null markerPath (no
825
+ * project dir → no setup to wait on) reads as alive so the caller's own
826
+ * grace/ceiling bound governs.
775
827
  * @param {string | null} markerPath
776
828
  * @returns {boolean}
777
829
  */
778
830
  export function probeSetupAlive(markerPath) {
779
831
  if (markerPath === null) return true;
780
- let pid;
832
+ let raw;
781
833
  try {
782
- pid = parseInt(readFileSync(markerPath, "utf8"), 10);
834
+ raw = readFileSync(markerPath, "utf8");
783
835
  } catch {
784
836
  return true;
785
837
  }
838
+ const lines = raw.split("\n").map((line) => line.trim());
839
+ if (lines.includes(SETUP_LOCK_DECLARATION)) {
840
+ const held = markerLockHeld(markerPath);
841
+ // null only: a host without flock(1) still gets the pid reading below rather
842
+ // than an answer this probe cannot support.
843
+ if (held !== null) return held;
844
+ }
845
+ const pid = parseInt(lines[0], 10);
786
846
  if (!Number.isInteger(pid) || pid <= 0) return true;
787
847
  try {
788
848
  process.kill(pid, 0);
@@ -10,19 +10,26 @@
10
10
  * Both hooks reach the state through this module so the paths and the trust rule
11
11
  * have one definition.
12
12
  */
13
- import { lstatSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
14
- import { createHash } from "node:crypto";
13
+ import {
14
+ lstatSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ rmSync,
19
+ } from "node:fs";
20
+ import { randomBytes } from "node:crypto";
15
21
  import { basename, join } from "node:path";
16
- import { tmpdir } from "node:os";
22
+ import { tmpdir, userInfo } from "node:os";
17
23
  import {
18
24
  lazyImport,
19
25
  markerIsTrusted,
26
+ PROJECT_HASH,
20
27
  scrubUntrustedText,
21
28
  writeFileNoFollow,
22
29
  writeSentinelFile,
23
30
  } from "./hook-io.mjs";
24
31
 
25
- // Layer-1 scrubber for the untrusted ALERT_FILE contents the gate splices into a
32
+ // Layer-1 scrubber for the untrusted alert-store contents the gate splices into a
26
33
  // permissionDecisionReason. Bound via lazyImport (see its doc for the fail-OPEN
27
34
  // hazard of a bare static npm import): a load failure leaves applyLayer1 undefined,
28
35
  // so scrubUntrustedText throws into the caller's fail-closed catch (→ ask) rather
@@ -31,50 +38,69 @@ const { applyLayer1 } = /** @type {typeof import("agent-sanitizer")} */ (
31
38
  await lazyImport("agent-sanitizer")
32
39
  );
33
40
 
34
- /** The project the hooks are guarding; the alert paths are keyed to it. */
35
- export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
36
-
37
- /** Short project digest keying this project's $TMPDIR marker names. */
38
- export const PROJECT_HASH = createHash("sha256")
39
- .update(PROJECT_DIR)
40
- .digest("hex")
41
- .slice(0, 8);
42
-
43
- /** Findings the SessionStart scanner could not clean, for the PreToolUse gate. */
44
- export const ALERT_FILE = join(
41
+ /**
42
+ * The path prefix every alert artifact of this PROJECT shares. Never a file
43
+ * itself — only {@link sessionPrefix} and the sweep read it — so that one
44
+ * `startsWith` covers every artifact the sweep must age out.
45
+ */
46
+ export const ALERT_BASE = join(
45
47
  tmpdir(),
46
48
  `.claude-invisible-char-alert-${PROJECT_HASH}`,
47
49
  );
48
50
 
49
- // Companion marker the PreToolUse gate writes once it has surfaced the alert
50
- // this session, so the gate asks ONCE then degrades to a passive reminder
51
- // instead of prompting on every tool call. Cleared at SessionStart alongside
52
- // ALERT_FILE so each fresh session re-asks once.
53
- export const ALERT_ACK_FILE = `${ALERT_FILE}.acked`;
54
-
55
51
  /**
56
- * Marker the InstructionsLoaded scanner writes on every fire, so another hook
57
- * can tell whether that event is being scanned at all this session.
52
+ * The path prefix every alert artifact of ONE session under this project shares.
58
53
  *
59
- * Keyed by the SESSION, and never cleared at SessionStart like the alert pair
60
- * above (a later session sweeps it once it is older than the TTL):
61
- * nothing pins the order of SessionStart against the InstructionsLoaded events
62
- * Claude Code fires for the files it loads at launch, so a clear could erase a
63
- * marker written moments earlier and produce the notice on a session that IS
64
- * covered. Session-keyed, the question each session asks is answered by that
65
- * session's own file and no ordering matters. A host that exports no session id
66
- * falls back to one shared name where the marker can outlive its session, and
67
- * a later session on a host that stopped emitting the event stays quiet.
68
- * @param {string} [sessionId]
54
+ * Session-keying is what makes the gate's one-time ask correct by construction.
55
+ * The store and its ack used to be keyed by PROJECT alone and reset by a
56
+ * destructive clear at SessionStart, which left two ways for a session to
57
+ * inherit the previous one's answer: an early-exiting scanner arm (a dep-load
58
+ * failure) returns before the clear, and nothing pins SessionStart against the
59
+ * InstructionsLoaded events fired for the files loaded at launch. A session that
60
+ * cannot see another session's files needs neither the clear nor the ordering
61
+ * past sessions' artifacts simply age out through {@link sweepStaleSessions}.
62
+ * @param {string} [sessionId] the harness's session identity
69
63
  * @returns {string}
70
64
  */
71
- export function instructionsLoadedFile(sessionId) {
65
+ export function sessionPrefix(sessionId) {
72
66
  // The id becomes a path component, so anything outside this class — a `/` in
73
67
  // a hostile session id above all — is folded away rather than escaping
74
- // $TMPDIR.
68
+ // $TMPDIR. A host that exports no session id falls back to one shared name,
69
+ // where a marker can outlive its session.
75
70
  const key =
76
71
  (sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
77
- return `${ALERT_FILE}.instructions-loaded.${key}`;
72
+ return `${ALERT_BASE}.s-${key}`;
73
+ }
74
+
75
+ /**
76
+ * The directory holding this session's alert findings, one file per finding.
77
+ * @param {string} [sessionId]
78
+ * @returns {string}
79
+ */
80
+ export function alertDir(sessionId) {
81
+ return `${sessionPrefix(sessionId)}.alerts`;
82
+ }
83
+
84
+ /**
85
+ * Companion marker the PreToolUse gate writes once it has surfaced the alert
86
+ * this session, so the gate asks ONCE then degrades to a passive reminder
87
+ * instead of prompting on every tool call. Session-keyed like the findings it
88
+ * answers for, so a fresh session cannot read an older session's answer.
89
+ * @param {string} [sessionId]
90
+ * @returns {string}
91
+ */
92
+ export function alertAckFile(sessionId) {
93
+ return `${sessionPrefix(sessionId)}.acked`;
94
+ }
95
+
96
+ /**
97
+ * Marker the InstructionsLoaded scanner writes on every fire, so another hook
98
+ * can tell whether that event is being scanned at all this session.
99
+ * @param {string} [sessionId]
100
+ * @returns {string}
101
+ */
102
+ export function instructionsLoadedFile(sessionId) {
103
+ return `${sessionPrefix(sessionId)}.instructions-loaded`;
78
104
  }
79
105
 
80
106
  /**
@@ -99,27 +125,65 @@ export function instructionsLoadedSeen(sessionId) {
99
125
  return markerIsTrusted(instructionsLoadedFile(sessionId));
100
126
  }
101
127
 
102
- /** How long a past session's marker is kept before the next session sweeps it. */
128
+ /** How long a past session's artifacts are kept before a later session sweeps. */
103
129
  const MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
104
130
 
105
131
  /**
106
- * Delete this project's session markers older than the TTL. Only PAST sessions'
107
- * files are candidates the current session's was just written, so the sweep
108
- * cannot answer its own question wrong.
109
- * @param {string} keep the marker path this session owns
132
+ * Whether `path` is a real directory this uid owns the directory counterpart
133
+ * of markerIsTrusted. `lstat`, so a symlink planted at the predictable alert-dir
134
+ * path is judged on ITSELF: followed, it would let a co-tenant aim the gate's
135
+ * reader at a directory of unrelated files this uid owns and splice their bytes
136
+ * into a permission prompt.
137
+ * @param {string} path
138
+ * @returns {boolean}
139
+ */
140
+ function dirIsTrusted(path) {
141
+ let st;
142
+ try {
143
+ st = lstatSync(path);
144
+ } catch {
145
+ return false;
146
+ }
147
+ return st.isDirectory() && st.uid === userInfo().uid;
148
+ }
149
+
150
+ /**
151
+ * Delete this project's artifacts from OTHER sessions once they are older than
152
+ * the TTL. The current session's own prefix is skipped, so the sweep can never
153
+ * answer its own question wrong; every other session's files are past history
154
+ * that nothing reads.
155
+ *
156
+ * This replaces the destructive SessionStart clear: with the store session-keyed
157
+ * there is nothing to reset, only old state to age out.
158
+ * @param {string} [sessionId] the session whose artifacts must be kept
110
159
  * @returns {void}
111
160
  */
112
- function sweepStaleMarkers(keep) {
161
+ export function sweepStaleSessions(sessionId) {
113
162
  const dir = tmpdir();
114
- const prefix = `${basename(ALERT_FILE)}.instructions-loaded.`;
163
+ const prefix = `${basename(ALERT_BASE)}.s-`;
164
+ const keep = basename(sessionPrefix(sessionId));
115
165
  const cutoff = Date.now() - MARKER_TTL_MS;
116
166
  for (const name of readdirSync(dir)) {
117
167
  if (!name.startsWith(prefix)) continue;
168
+ // Whole-segment match on the keep prefix, not a bare startsWith: session key
169
+ // "abc" must not claim (and so preserve) session key "abc2"'s artifacts.
170
+ if (name === keep || name.startsWith(`${keep}.`)) continue;
118
171
  const path = join(dir, name);
119
- if (path === keep || path === `${keep}.noticed`) continue;
120
- // lstat, not stat: a squatted symlink at a predictable $TMPDIR path must be
121
- // judged on ITSELF, not on whatever it points at. unlink removes the link.
122
- if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
172
+ try {
173
+ // lstat, not stat: a squatted symlink at a predictable $TMPDIR path must
174
+ // be judged on ITSELF, not on whatever it points at.
175
+ if (lstatSync(path).mtimeMs >= cutoff) continue;
176
+ rmSync(path, { recursive: true, force: true });
177
+ } catch (err) {
178
+ // ENOENT: a parallel session swept this entry between readdir and lstat.
179
+ // EPERM/EACCES: a co-tenant's entry sharing the prefix, which is not ours
180
+ // to remove. Both are benign races on a shared $TMPDIR, and a throw here
181
+ // would abort recordInstructionsLoaded and render the "instruction file
182
+ // was NOT scanned" fault on a session that WAS scanned. Anything else is
183
+ // a bug in this sweep and propagates.
184
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
185
+ if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
186
+ }
123
187
  }
124
188
  }
125
189
 
@@ -128,8 +192,8 @@ function sweepStaleMarkers(keep) {
128
192
  * write (see writeSentinelFile) at a predictable $TMPDIR path.
129
193
  *
130
194
  * The event fires once per instruction file loaded, so the already-recorded case
131
- * returns without a write — and the stale-marker sweep rides the FIRST fire of a
132
- * session, where one readdir is paid once rather than per loaded file.
195
+ * returns without a write — and the stale-session sweep rides the FIRST fire of
196
+ * a session, where one readdir is paid once rather than per loaded file.
133
197
  * @param {string} [sessionId]
134
198
  * @returns {void}
135
199
  */
@@ -137,14 +201,18 @@ export function recordInstructionsLoaded(sessionId) {
137
201
  const marker = instructionsLoadedFile(sessionId);
138
202
  if (markerIsTrusted(marker)) return;
139
203
  writeSentinelFile(marker);
140
- sweepStaleMarkers(marker);
204
+ sweepStaleSessions(sessionId);
141
205
  }
142
206
 
143
207
  /**
144
208
  * The one-time context line for a session where no InstructionsLoaded scan ran,
145
209
  * or null when the scan has been seen or the notice was already surfaced this
146
- * session. Records the notice as it hands it out, so it rides on ONE tool call
147
- * rather than every one — the per-call repeat is what trains a reader to skip it.
210
+ * session.
211
+ *
212
+ * PURE: it does not record that the notice was handed out. The caller records
213
+ * separately, once the notice has actually landed in a response — a deny
214
+ * assembled after this call discards the notice, and a marker written here would
215
+ * have burned the session's one chance to report the loss.
148
216
  *
149
217
  * The loss it names is real and otherwise invisible: SessionStart scans the
150
218
  * instruction files that load at launch, and everything a subdirectory loads
@@ -162,9 +230,7 @@ export function recordInstructionsLoaded(sessionId) {
162
230
  */
163
231
  export function instructionsLoadedGapNotice(sessionId) {
164
232
  if (instructionsLoadedSeen(sessionId)) return null;
165
- const noticeFile = instructionsLoadedNoticeFile(sessionId);
166
- if (markerIsTrusted(noticeFile)) return null;
167
- writeSentinelFile(noticeFile);
233
+ if (markerIsTrusted(instructionsLoadedNoticeFile(sessionId))) return null;
168
234
  return (
169
235
  "agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
170
236
  "instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
@@ -178,65 +244,114 @@ export function instructionsLoadedGapNotice(sessionId) {
178
244
  );
179
245
  }
180
246
 
247
+ /**
248
+ * Record that the gap notice above was surfaced, so it rides on ONE tool call
249
+ * rather than every one — the per-call repeat is what trains a reader to skip it.
250
+ * Called only once the notice is in a response that is actually being returned.
251
+ * @param {string} [sessionId]
252
+ * @returns {void}
253
+ */
254
+ export function recordInstructionsLoadedNotice(sessionId) {
255
+ writeSentinelFile(instructionsLoadedNoticeFile(sessionId));
256
+ }
257
+
181
258
  /**
182
259
  * The alert findings if invisible-char injection was detected in instruction
183
- * files and couldn't be auto-cleaned, else null. ALERT_FILE lives at a predictable,
184
- * world-visible $TMPDIR path, so its contents are attacker-writable (a co-tenant can
185
- * plant a file/symlink there): trust it only when markerIsTrusted confirms a regular
186
- * file THIS uid owns (a squatted symlink/foreign file reads as no alert), then scrub
187
- * the bytes through Layer-1 before any caller splices them into a reason the report
188
- * would otherwise carry ANSI/invisible spoofing into the model's context.
260
+ * files and couldn't be auto-cleaned, else null.
261
+ *
262
+ * The store is a DIRECTORY of one file per finding, all at predictable,
263
+ * world-visible $TMPDIR paths, so both the directory and every entry in it are
264
+ * attacker-plantable: trust the directory only when it is a real directory this
265
+ * uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
266
+ * each entry only when markerIsTrusted confirms a regular file this uid owns,
267
+ * then scrub the bytes through Layer-1 before any caller splices them into a
268
+ * reason — the report would otherwise carry ANSI/invisible spoofing into the
269
+ * model's context.
270
+ * This session's store AND the shared `no-session` fallback, because a hook that
271
+ * faults BEFORE it can parse its payload has no session identity to key by: its
272
+ * finding lands in the fallback, and a strictly session-keyed read would leave
273
+ * the one report of an unscanned instruction file unreachable. The ack stays
274
+ * strictly session-keyed, so the gate still asks exactly once per session.
275
+ * @param {string} [sessionId]
189
276
  * @returns {string | null}
190
277
  */
191
- export function invisibleCharAlert() {
192
- if (!markerIsTrusted(ALERT_FILE)) return null;
193
- const raw = readFileSync(ALERT_FILE, "utf-8").trim();
194
- return scrubUntrustedText(raw, applyLayer1);
278
+ export function invisibleCharAlert(sessionId) {
279
+ const dirs = [alertDir(sessionId)];
280
+ if (dirs[0] !== alertDir()) dirs.push(alertDir());
281
+ const parts = [];
282
+ for (const dir of dirs) {
283
+ if (!dirIsTrusted(dir)) continue;
284
+ // Sorted so a multi-finding report reads the same on every call; the names
285
+ // are random, so the order carries no meaning beyond being stable.
286
+ for (const name of readdirSync(dir).sort()) {
287
+ const path = join(dir, name);
288
+ if (!markerIsTrusted(path)) continue;
289
+ const text = readFileSync(path, "utf-8").trim();
290
+ if (text !== "") parts.push(text);
291
+ }
292
+ }
293
+ if (parts.length === 0) return null;
294
+ return scrubUntrustedText(parts.join("\n"), applyLayer1);
195
295
  }
196
296
 
197
297
  /**
198
- * Add `text` to the alert the PreToolUse gate surfaces, keeping whatever is
199
- * already there.
298
+ * Add `text` to the alert the PreToolUse gate surfaces this session, keeping
299
+ * whatever is already there.
200
300
  *
201
- * Appending, where the SessionStart scanner TRUNCATES: that scan runs once and
202
- * owns the session's reset, while an instruction file loaded mid-session is one
203
- * more finding on top of whatever the launch scan left a truncating write here
204
- * would silently drop the earlier report. Symlink-refusing (writeFileNoFollow)
205
- * and ownership-checked on read, because ALERT_FILE sits at a predictable,
206
- * world-visible $TMPDIR path; a foreign or squatted file reads as empty and is
207
- * replaced rather than appended to.
301
+ * One O_EXCL-created, randomly-named file per finding. The store used to be a
302
+ * single file appended through a read-modify-write, so two hooks recording a
303
+ * finding at once silently dropped one of them; a fresh file per finding has no
304
+ * shared cell to lose. Symlink-refusing (writeFileNoFollow) because the store
305
+ * sits at a predictable, world-visible $TMPDIR path.
208
306
  * @param {string} text
209
- * @returns {void}
307
+ * @param {string} [sessionId]
308
+ * @returns {boolean} whether the finding was recorded
210
309
  */
211
- export function appendAlert(text) {
212
- const existing = markerIsTrusted(ALERT_FILE)
213
- ? readFileSync(ALERT_FILE, "utf-8")
214
- : "";
215
- writeFileNoFollow(ALERT_FILE, existing + text + "\n");
310
+ export function appendAlert(text, sessionId) {
311
+ const dir = alertDir(sessionId);
312
+ const path = join(dir, randomBytes(8).toString("hex"));
313
+ // Caught, not propagated: one caller is the fault handler that reports a hook
314
+ // crash, and a throw there would replace the report with a second crash. The
315
+ // loss is announced on stderr instead — never swallowed.
316
+ let usable;
317
+ try {
318
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
319
+ usable = dirIsTrusted(dir);
320
+ } catch {
321
+ usable = false;
322
+ }
323
+ if (usable && writeFileNoFollow(path, text + "\n")) return true;
324
+ process.stderr.write(
325
+ "agent-sanitizer: could not record an instruction-file finding under " +
326
+ `${dir}; the PreToolUse gate will NOT surface it this session.\n`,
327
+ );
328
+ return false;
216
329
  }
217
330
 
218
331
  /**
219
332
  * True once the gate has surfaced its blocking ask this session. Validates
220
- * ownership (not mere existence): a co-tenant could pre-create ALERT_ACK_FILE at its
221
- * predictable $TMPDIR path to permanently suppress the one-time blocking ask down to
222
- * the passive reminder, so trust the marker only when it is a regular file this uid
223
- * wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
333
+ * ownership (not mere existence): a co-tenant could pre-create the ack at its
334
+ * predictable $TMPDIR path to permanently suppress the one-time blocking ask down
335
+ * to the passive reminder, so trust the marker only when it is a regular file
336
+ * this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
337
+ * @param {string} [sessionId]
224
338
  * @returns {boolean}
225
339
  */
226
- export function alertAcknowledged() {
227
- return markerIsTrusted(ALERT_ACK_FILE);
340
+ export function alertAcknowledged(sessionId) {
341
+ return markerIsTrusted(alertAckFile(sessionId));
228
342
  }
229
343
 
230
344
  /**
231
345
  * Record that the gate has surfaced its blocking ask, so later tool calls get a
232
- * passive reminder instead of an ask on every call. Cleared at SessionStart by
233
- * the scanner so each fresh session re-asks once.
346
+ * passive reminder instead of an ask on every call. Session-keyed, so the next
347
+ * session re-asks once without anything having to clear this.
348
+ * @param {string} [sessionId]
234
349
  * @returns {void}
235
350
  */
236
- export function acknowledgeAlert() {
237
- // Symlink-safe presence write: ALERT_ACK_FILE sits at a predictable $TMPDIR
238
- // path a co-tenant could pre-plant a symlink at (see writeSentinelFile).
239
- writeSentinelFile(ALERT_ACK_FILE);
351
+ export function acknowledgeAlert(sessionId) {
352
+ // Symlink-safe presence write: the ack sits at a predictable $TMPDIR path a
353
+ // co-tenant could pre-plant a symlink at (see writeSentinelFile).
354
+ writeSentinelFile(alertAckFile(sessionId));
240
355
  }
241
356
 
242
357
  // What the operator can actually DO — one bullet per kind of report the alert
@@ -466,6 +466,8 @@ export async function redactViaDaemon(text, opts = {}) {
466
466
  // reading `result.pairs` on it would silently emit the original secret-shaped
467
467
  // content. Validated AFTER the respawn/retry logic so a malformed response is not
468
468
  // mistaken for a dead socket worth respawning.
469
+ // Each throw carries the BARE cause: every call sits inside a catch that
470
+ // wraps what it caught, so wrapping here too would nest the sentence in itself.
469
471
  /** @param {RedactResponse|null} result @returns {RedactResponse|null} */
470
472
  const validate = (result) => {
471
473
  if (result === null) return null;
@@ -474,18 +476,14 @@ export async function redactViaDaemon(text, opts = {}) {
474
476
  result?.unmappable === undefined &&
475
477
  !(typeof result?.text === "string" && Array.isArray(result?.pairs))
476
478
  )
477
- throw failClosed(
478
- new Error(
479
- "redactor returned a malformed map response (no `unmappable` marker and no `{text, pairs}` map)",
480
- ),
479
+ throw new Error(
480
+ "redactor returned a malformed map response (no `unmappable` marker and no `{text, pairs}` map)",
481
481
  );
482
482
  return result;
483
483
  }
484
484
  if (typeof result?.text !== "string")
485
- throw failClosed(
486
- new Error(
487
- "redactor returned a malformed plain response (no string `text`)",
488
- ),
485
+ throw new Error(
486
+ "redactor returned a malformed plain response (no string `text`)",
489
487
  );
490
488
  return result;
491
489
  };
@@ -503,17 +501,22 @@ export async function redactViaDaemon(text, opts = {}) {
503
501
  // within the deadline; surface that as the actual cause rather than the opaque
504
502
  // ENOENT/connect error the retry would otherwise throw.
505
503
  const budgetMs = remainingMs();
506
- const waitOpts =
504
+ const waitMs =
507
505
  budgetMs === undefined
508
- ? undefined
509
- : { deadlineMs: Math.min(WAIT_DEADLINE_MS, budgetMs) };
506
+ ? WAIT_DEADLINE_MS
507
+ : Math.min(WAIT_DEADLINE_MS, budgetMs);
508
+ const waitOpts =
509
+ budgetMs === undefined ? undefined : { deadlineMs: waitMs };
510
510
  // The cold-start wait is PROVISIONING — a one-time detect-secrets import and
511
511
  // plugin prime (~1-3s, see WAIT_DEADLINE_MS), paid by whichever tool call
512
512
  // happens to be first and by no other. Charging it to this hook would make
513
513
  // the slow-hook notice fire once per session on a healthy install.
514
514
  if (!(await excludeProvisioning(() => waitFn(socketPath, waitOpts), now)))
515
515
  throw failClosed(
516
- new Error(`redactor daemon did not start within ${WAIT_DEADLINE_MS}ms`),
516
+ // The CLAMPED wait, since that is what actually elapsed: naming
517
+ // WAIT_DEADLINE_MS tells an operator debugging a 200ms budget that the
518
+ // redactor got seconds it was never given.
519
+ new Error(`redactor daemon did not start within ${waitMs}ms`),
517
520
  );
518
521
  if (budgetSpent()) throw outOfBudget("after redactor respawn");
519
522
  try {