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.
@@ -21,8 +21,11 @@
21
21
  */
22
22
  import { createHash } from "node:crypto";
23
23
  import {
24
+ existsSync,
24
25
  mkdirSync,
25
26
  lstatSync,
27
+ readdirSync,
28
+ unlinkSync,
26
29
  openSync,
27
30
  readFileSync,
28
31
  closeSync,
@@ -30,7 +33,7 @@ import {
30
33
  } from "node:fs";
31
34
  import { tmpdir, userInfo } from "node:os";
32
35
  import { join, resolve, sep } from "node:path";
33
- import { writeFileNoFollow } from "./hook-io.mjs";
36
+ import { PROJECT_HASH, writeFileNoFollow } from "./hook-io.mjs";
34
37
 
35
38
  /**
36
39
  * Where reveal sidecars are stored. Exported so the PreToolUse placeholder
@@ -39,12 +42,56 @@ import { writeFileNoFollow } from "./hook-io.mjs";
39
42
  * @returns {string}
40
43
  */
41
44
  export function revealDir() {
45
+ // Project-keyed like every other $TMPDIR store: one unkeyed directory is shared
46
+ // by every project on the machine, so one project's sweep ages out another's
47
+ // spans and a rehydration that should have restored a placeholder fails closed
48
+ // instead. The 12-hex span KEY inside the directory is untouched — it is the
49
+ // published placeholder grammar, not a path detail.
42
50
  return (
43
51
  process.env._AGENT_SANITIZER_REVEAL_DIR ||
44
- join(tmpdir(), "agent-sanitizer-layer2-reveal")
52
+ join(tmpdir(), `agent-sanitizer-layer2-reveal-${PROJECT_HASH}`)
45
53
  );
46
54
  }
47
55
 
56
+ /**
57
+ * How long a reveal sidecar or span file is kept before a later session sweeps it.
58
+ * It must outlast the longest session that could still rehydrate a placeholder the
59
+ * model is holding, so it is generous rather than tight — these files are small,
60
+ * and the cost of sweeping one too early is a rehydration that fails closed.
61
+ */
62
+ export const REVEAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
63
+
64
+ /**
65
+ * Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}.
66
+ *
67
+ * Nothing else removes them: every entry is content-addressed, so a store that is
68
+ * never swept grows for the life of the machine. Called from SessionStart, the one
69
+ * touchpoint that runs once per session rather than once per tool call.
70
+ * @returns {void}
71
+ */
72
+ export function sweepStaleReveals() {
73
+ const dir = revealDir();
74
+ // existsSync first: revealDirIsSafe CREATES the directory, and a sweep run on
75
+ // every session start must not leave an empty store behind for a project that
76
+ // never spliced anything.
77
+ if (!existsSync(dir) || !revealDirIsSafe(dir)) return;
78
+ const cutoff = Date.now() - REVEAL_TTL_MS;
79
+ for (const name of readdirSync(dir)) {
80
+ const path = join(dir, name);
81
+ try {
82
+ // lstat, not stat: a squatted symlink at a precomputable path is judged on
83
+ // ITSELF, and unlink removes the link rather than its target.
84
+ if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
85
+ } catch (err) {
86
+ // ENOENT: a parallel session swept this entry between readdir and lstat.
87
+ // EPERM/EACCES: a co-tenant's entry, which is not ours to remove. Anything
88
+ // else is a bug in this sweep and propagates.
89
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
90
+ if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
91
+ }
92
+ }
93
+ }
94
+
48
95
  /**
49
96
  * Content-addressed path the pre-splice text of `content` is stored at.
50
97
  * @param {string} content
@@ -28,12 +28,16 @@
28
28
  * pass costs only what today's behavior already allows.
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
- import { lstatSync, unlinkSync } from "node:fs";
31
+ import { lstatSync, readdirSync, unlinkSync } from "node:fs";
32
32
  import { join, dirname } from "node:path";
33
33
  import { tmpdir } from "node:os";
34
34
  import { spawnSync } from "node:child_process";
35
- import { lazyImport, markerIsTrusted, writeSentinelFile } from "./hook-io.mjs";
36
- import { PROJECT_HASH } from "./invisible-alert.mjs";
35
+ import {
36
+ lazyImport,
37
+ markerIsTrusted,
38
+ PROJECT_HASH,
39
+ writeSentinelFile,
40
+ } from "./hook-io.mjs";
37
41
 
38
42
  // Layer-1 view primitives, bound via lazyImport (see its doc for the fail-OPEN
39
43
  // hazard of a bare static npm import): a load failure leaves these undefined,
@@ -75,6 +79,37 @@ export function confirmMarkerPath(fingerprint) {
75
79
  return join(tmpdir(), `.claude-secret-drop-${PROJECT_HASH}-${fingerprint}`);
76
80
  }
77
81
 
82
+ /**
83
+ * Delete this project's confirm sentinels older than {@link CONFIRM_TTL_MS}.
84
+ *
85
+ * consumeConfirm removes the sentinel it honors, but an abandoned confirmation —
86
+ * denied, never retried — is removed by nothing, so the store grows for the life
87
+ * of the machine. Called from SessionStart, the one touchpoint that runs once per
88
+ * session rather than once per tool call. A sentinel past the TTL is already inert
89
+ * (consumeConfirm refuses it), so this reclaims space without changing a verdict.
90
+ * @returns {void}
91
+ */
92
+ export function sweepStaleConfirms() {
93
+ const dir = tmpdir();
94
+ const prefix = `.claude-secret-drop-${PROJECT_HASH}-`;
95
+ const cutoff = Date.now() - CONFIRM_TTL_MS;
96
+ for (const name of readdirSync(dir)) {
97
+ if (!name.startsWith(prefix)) continue;
98
+ const path = join(dir, name);
99
+ try {
100
+ // lstat, not stat: a squatted symlink at a predictable path is judged on
101
+ // ITSELF, and unlink removes the link rather than its target.
102
+ if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
103
+ } catch (err) {
104
+ // ENOENT: a parallel session swept this entry between readdir and lstat.
105
+ // EPERM/EACCES: a co-tenant's entry, which is not ours to remove. Anything
106
+ // else is a bug in this sweep and propagates.
107
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
108
+ if (code !== "ENOENT" && code !== "EPERM" && code !== "EACCES") throw err;
109
+ }
110
+ }
111
+ }
112
+
78
113
  /**
79
114
  * Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
80
115
  * (not a repository) both mean "no git recovery exists", which is what the
@@ -165,6 +200,12 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
165
200
  if (dropped.length === 0) return null;
166
201
 
167
202
  const fingerprint = dropFingerprint(filePath, content, dropped);
203
+ // THE INVARIANT THIS ENFORCES: a confirmation approves exactly one drop —
204
+ // the identical (path, bytes, drop-set) — and only for CONFIRM_TTL_MS. The
205
+ // window must outlive a model retry (the confirmation IS the retry, so a
206
+ // shorter one would deny forever) and must not outlive the session's working
207
+ // context, or a marker left by an abandoned attempt becomes a standing
208
+ // auto-approval for a Write the user never saw.
168
209
  if (confirmSeen(fingerprint)) return null;
169
210
  recordConfirm(fingerprint);
170
211
  return { deny: dropDeny(dropped.length, filePath) };
@@ -154,11 +154,14 @@ const HOOKS = {
154
154
  "scan-invisible-chars": {
155
155
  event: HookEvent.SESSION_START,
156
156
  run: async () => {
157
- const { cliMain } =
157
+ const { cliMain, sessionIdFromStdin } =
158
158
  /** @type {typeof import("./scan-invisible-chars.mjs")} */ (
159
159
  await import("./scan-invisible-chars.mjs")
160
160
  );
161
- await cliMain();
161
+ // The SessionStart payload carries the session identity the alert store is
162
+ // keyed by; without it every session shares one store and inherits the
163
+ // previous one's gate acknowledgement.
164
+ await cliMain({ sessionId: await sessionIdFromStdin() });
162
165
  },
163
166
  },
164
167
  "scan-loaded-instructions": {
@@ -55,6 +55,7 @@ import {
55
55
  gateReminderContext,
56
56
  alertAcknowledged,
57
57
  acknowledgeAlert,
58
+ recordInstructionsLoadedNotice,
58
59
  instructionsLoadedGapNotice,
59
60
  } from "./lib/invisible-alert.mjs";
60
61
  import {
@@ -448,10 +449,10 @@ export async function buildPreToolUseResponse(
448
449
  // Layer 1: gate. Persists across the session until the injected files are
449
450
  // cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
450
451
  // degrades to a passive reminder, so it doesn't prompt on every tool call.
451
- const findings = invisibleCharAlert();
452
+ const findings = invisibleCharAlert(input.session_id);
452
453
  let pendingGateAck = false;
453
454
  if (findings) {
454
- if (alertAcknowledged()) {
455
+ if (alertAcknowledged(input.session_id)) {
455
456
  contexts.push(gateReminderContext());
456
457
  } else {
457
458
  asks.push(gateAskReason(findings));
@@ -461,11 +462,11 @@ export async function buildPreToolUseResponse(
461
462
 
462
463
  const { tool_name: tool, tool_input: toolInput } = input;
463
464
 
464
- // Coverage notice, once per session: with no InstructionsLoaded scan running,
465
- // every instruction file loaded from a subdirectory reaches the model
466
- // unscanned, and the SessionStart scan — which covers only what loads at
467
- // launch — cannot see the loss. Reported here because this is the first hook
468
- // that runs after the loads would have happened.
465
+ // Reported here because this is the first hook that runs after the launch-time
466
+ // loads would have happened. assembleResponse — not this call — records that
467
+ // the notice was surfaced: a rehydrate deny below returns before the response
468
+ // is assembled, and recording here would burn the session's one report on a
469
+ // call that never carried it.
469
470
  const gapNotice = instructionsLoadedGapNotice(input.session_id);
470
471
  if (gapNotice !== null) contexts.push(gapNotice);
471
472
 
@@ -504,15 +505,25 @@ export async function buildPreToolUseResponse(
504
505
  return emitTraced(
505
506
  emitTrace,
506
507
  input.tool_name,
507
- assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
508
+ assembleResponse({
509
+ changed,
510
+ current,
511
+ asks,
512
+ contexts,
513
+ pendingGateAck,
514
+ pendingGapNotice: gapNotice !== null,
515
+ sessionId: input.session_id,
516
+ }),
508
517
  );
509
518
  }
510
519
 
511
520
  /**
512
521
  * Assemble the hookSpecificOutput fields from the per-layer results, or null
513
522
  * for a clean no-op (nothing asked, changed, or annotated). Records the gate
514
- * acknowledgement only when an ask actually lands in the response.
515
- * @param {{ changed: boolean, current: any, asks: string[], contexts: string[], pendingGateAck: boolean }} parts
523
+ * acknowledgement and the coverage-gap notice only when they actually land in
524
+ * the response.
525
+ * @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
526
+ * pendingGateAck: boolean, pendingGapNotice: boolean, sessionId?: string }} parts
516
527
  * @returns {Record<string, unknown> | null}
517
528
  */
518
529
  function assembleResponse({
@@ -521,6 +532,8 @@ function assembleResponse({
521
532
  asks,
522
533
  contexts,
523
534
  pendingGateAck,
535
+ pendingGapNotice,
536
+ sessionId,
524
537
  }) {
525
538
  if (asks.length === 0 && !changed && contexts.length === 0) return null;
526
539
 
@@ -541,7 +554,8 @@ function assembleResponse({
541
554
  if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
542
555
  // Record the gate ack only now that the ask is actually in the response — a
543
556
  // rehydrate deny above returns first, so a preempted ask is not marked seen.
544
- if (pendingGateAck) acknowledgeAlert();
557
+ if (pendingGateAck) acknowledgeAlert(sessionId);
558
+ if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
545
559
  return fields;
546
560
  }
547
561
 
@@ -21,7 +21,7 @@
21
21
  * false positive the SSOT had already fixed, and its clean path was a bare
22
22
  * `writeFileSync` with none of cleanFile's symlink/UTF-8/TOCTOU guards.
23
23
  */
24
- import { existsSync, readFileSync, globSync, unlinkSync } from "node:fs";
24
+ import { existsSync, readFileSync, globSync } from "node:fs";
25
25
  import { join, relative, resolve } from "node:path";
26
26
  import {
27
27
  awaitLazyDependency,
@@ -33,7 +33,8 @@ import {
33
33
  lazyImport,
34
34
  markerIsTrusted,
35
35
  probeSetupAlive,
36
- writeFileNoFollow,
36
+ PROJECT_DIR,
37
+ readStdinJson,
37
38
  } from "./lib/hook-io.mjs";
38
39
  import {
39
40
  registerFaultPolicy,
@@ -41,11 +42,14 @@ import {
41
42
  writeFaultOutcome,
42
43
  } from "./lib/hook-fault.mjs";
43
44
  import {
44
- ALERT_FILE,
45
- ALERT_ACK_FILE,
46
- PROJECT_DIR,
45
+ alertAckFile,
46
+ alertDir,
47
+ appendAlert,
48
+ sweepStaleSessions,
47
49
  } from "./lib/invisible-alert.mjs";
48
50
  import { formatReport } from "./lib/invisible-report.mjs";
51
+ import { sweepStaleReveals } from "./lib/reveal.mjs";
52
+ import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
49
53
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
50
54
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
51
55
  // Relative, not the `agent-sanitizer` specifier every other engine import uses:
@@ -181,19 +185,15 @@ function reportFault(err) {
181
185
 
182
186
  /**
183
187
  * Persist the accumulated alert text for the PreToolUse gate, or leave the alert
184
- * absent when there is nothing to surface.
185
- *
186
- * ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
187
- * writeFileSync would follow a co-tenant-planted symlink and overwrite an
188
- * arbitrary file this uid owns. Create it symlink-refusingly (see
189
- * writeFileNoFollow); the gate treats an absent alert as "nothing to surface",
190
- * so a lost race degrades safely rather than to a hijacked write.
188
+ * absent when there is nothing to surface. One store entry per part, into THIS
189
+ * session's alert directory (see appendAlert), so a concurrent
190
+ * InstructionsLoaded finding cannot clobber the launch scan's report.
191
191
  * @param {string[]} parts
192
+ * @param {string} [sessionId]
192
193
  * @returns {void}
193
194
  */
194
- function persistAlert(parts) {
195
- if (parts.length === 0) return;
196
- writeFileNoFollow(ALERT_FILE, parts.join("\n") + "\n");
195
+ function persistAlert(parts, sessionId) {
196
+ for (const part of parts) appendAlert(part, sessionId);
197
197
  }
198
198
 
199
199
  // Decoder
@@ -282,8 +282,8 @@ export {
282
282
  decodeRun,
283
283
  findInstructionFiles,
284
284
  scanFile,
285
- ALERT_FILE,
286
- ALERT_ACK_FILE,
285
+ alertAckFile,
286
+ alertDir,
287
287
  LONG_RUN_RE,
288
288
  LONG_RUN_THRESHOLD,
289
289
  TOTAL_INVISIBLE_THRESHOLD,
@@ -353,7 +353,7 @@ export function scanProject(dir = PROJECT_DIR) {
353
353
  continue;
354
354
  }
355
355
  // safeErrMessage, not errMessage: this reason is rendered into stderr and
356
- // into ALERT_FILE, and an errno message embeds the absolute path globbed
356
+ // into the alert store, and an errno message embeds the absolute path globbed
357
357
  // out of a possibly-hostile repo — a filename carrying ANSI or invisible
358
358
  // bytes would otherwise reach the operator's terminal raw.
359
359
  skipped.push({ file: report(file), reason: safeErrMessage(err) });
@@ -399,12 +399,15 @@ export function formatSkipped(skipped) {
399
399
  * @param {{
400
400
  * trace?: import("./lib/trace.mjs").TraceFn,
401
401
  * scan?: () => ReturnType<typeof scanProject>,
402
+ * sessionId?: string,
402
403
  * }} [opts] `trace` is where this scan announces engagement; a host with its
403
404
  * own trace channel passes its sink so the announcement lands where its
404
405
  * detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
405
406
  * FAULT path below — a scanner that throws something other than an errno, i.e.
406
407
  * a bug — is drivable end to end; no filesystem state can force it, and an
407
408
  * untested fault path is how a posture goes missing in the first place.
409
+ * `sessionId` keys the alert store this scan writes; the CLI entry reads it off
410
+ * the SessionStart payload, and an in-process caller passes it directly.
408
411
  * @returns {Promise<void>}
409
412
  */
410
413
  export async function cliMain(opts = {}) {
@@ -434,10 +437,11 @@ export async function cliMain(opts = {}) {
434
437
  * @param {{
435
438
  * trace?: import("./lib/trace.mjs").TraceFn,
436
439
  * scan?: () => ReturnType<typeof scanProject>,
440
+ * sessionId?: string,
437
441
  * }} opts see {@link cliMain}
438
442
  * @returns {Promise<void>}
439
443
  */
440
- async function runScanCli({ trace: sink = trace, scan: runScan }) {
444
+ async function runScanCli({ trace: sink = trace, scan: runScan, sessionId }) {
441
445
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
442
446
  // the alert write, with no catch above them, so a throwing host sink would
443
447
  // abort the scan silently (see bestEffortTrace).
@@ -470,20 +474,22 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
470
474
  ),
471
475
  ),
472
476
  );
473
- persistAlert(alertParts);
477
+ persistAlert(alertParts, sessionId);
474
478
  process.exit(1);
475
479
  }
476
480
  /* c8 ignore stop */
477
481
 
478
- // Clean up stale alert + its ack marker from a previous session so this
479
- // session re-surfaces the gate once if injection is still present.
480
- for (const stale of [ALERT_FILE, ALERT_ACK_FILE]) {
481
- try {
482
- unlinkSync(stale);
483
- } catch {
484
- // Doesn't exist or not writable
485
- }
486
- }
482
+ // No clear: the store is session-keyed, so this session starts empty by
483
+ // construction and cannot inherit a previous session's ack. Age out what past
484
+ // sessions left instead — SessionStart is the once-per-session touchpoint the
485
+ // sweep belongs on.
486
+ sweepStaleSessions(sessionId);
487
+ // The other two $TMPDIR stores these hooks own. Both are content- or
488
+ // fingerprint-addressed with no natural owner to delete them, so without a
489
+ // sweep they grow for the life of the machine; SessionStart is the only
490
+ // touchpoint that runs once per session rather than once per tool call.
491
+ sweepStaleReveals();
492
+ sweepStaleConfirms();
487
493
 
488
494
  // Only a non-errno throw reaches here — a bug in the scanner, not a file it
489
495
  // could not read (those are accounted for in `skipped`). It is a fault of THIS
@@ -495,7 +501,7 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
495
501
  } catch (err) {
496
502
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
497
503
  alertParts.push(...reportFault(err));
498
- persistAlert(alertParts);
504
+ persistAlert(alertParts, sessionId);
499
505
  return;
500
506
  }
501
507
  const { findings: allFindings, skipped, absent, scanned } = scan;
@@ -529,7 +535,7 @@ async function runScanCli({ trace: sink = trace, scan: runScan }) {
529
535
 
530
536
  if (allFindings.length > 0)
531
537
  alertParts.push(...autoCleanFindings(allFindings, PROJECT_DIR));
532
- persistAlert(alertParts);
538
+ persistAlert(alertParts, sessionId);
533
539
  }
534
540
 
535
541
  /**
@@ -602,6 +608,27 @@ function autoCleanFindings(allFindings, dir) {
602
608
  return [report];
603
609
  }
604
610
 
611
+ /**
612
+ * The session identity from the SessionStart payload on stdin, or undefined when
613
+ * the host sent none.
614
+ *
615
+ * Swallowing: the payload is read for ONE optional field, and a host that pipes
616
+ * nothing (or malformed JSON) must still get the scan — a session-start scan
617
+ * refused over an unparseable envelope is a strictly worse outcome than one
618
+ * keyed to the shared `no-session` fallback.
619
+ * @returns {Promise<string | undefined>}
620
+ */
621
+ export async function sessionIdFromStdin() {
622
+ // A TTY is a human running this scan by hand, not a harness sending an event:
623
+ // reading it would block forever waiting for a payload nobody is going to send.
624
+ if (process.stdin.isTTY) return undefined;
625
+ try {
626
+ return (await readStdinJson())?.session_id;
627
+ } catch {
628
+ return undefined;
629
+ }
630
+ }
631
+
605
632
  if (isMain(import.meta.url)) {
606
- await cliMain();
633
+ await cliMain({ sessionId: await sessionIdFromStdin() });
607
634
  }
@@ -24,6 +24,7 @@ import {
24
24
  HookEvent,
25
25
  isMain,
26
26
  lazyImport,
27
+ PROJECT_DIR,
27
28
  readStdinJson,
28
29
  safeErrMessage,
29
30
  } from "./lib/hook-io.mjs";
@@ -34,7 +35,6 @@ import {
34
35
  } from "./lib/hook-fault.mjs";
35
36
  import {
36
37
  appendAlert,
37
- PROJECT_DIR,
38
38
  recordInstructionsLoaded,
39
39
  } from "./lib/invisible-alert.mjs";
40
40
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
@@ -259,8 +259,11 @@ export { HOOK_NAME };
259
259
  export async function cliMain({ trace: sink = trace } = {}) {
260
260
  const timer = startHookTimer();
261
261
  const emitTrace = bestEffortTrace(sink);
262
+ /** @type {string | undefined} */
263
+ let sessionId;
262
264
  try {
263
265
  const payload = await readStdinJson();
266
+ sessionId = payload?.session_id;
264
267
  // Recorded before the scan, not after: the marker answers "is this event
265
268
  // being scanned", which is true the moment the hook is running, and a
266
269
  // faulting scan must not read as an unscanned event — that notice names a
@@ -289,7 +292,7 @@ export async function cliMain({ trace: sink = trace } = {}) {
289
292
  // A payload still on disk is the case the PreToolUse gate exists for: it
290
293
  // asks once, on the next tool call, rather than leaving the only report on a
291
294
  // channel that scrolls.
292
- if (!result.cleaned) appendAlert(message);
295
+ if (!result.cleaned) appendAlert(message, sessionId);
293
296
  // systemMessage reaches the user, additionalContext the model. Both, because
294
297
  // this hook cannot block and the file is already loaded: the user is the one
295
298
  // who can act on it, and the model is the one currently reading it.
@@ -306,7 +309,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
306
309
  emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
307
310
  const outcome = hookFaultOutcome(HOOK_NAME, err);
308
311
  process.exitCode = writeFaultOutcome(outcome);
309
- if (outcome.armAlert) appendAlert(/** @type {string} */ (outcome.stderr));
312
+ // `sessionId`, captured before the throw: a payload read that itself failed
313
+ // leaves it undefined, and the fault then lands in the shared fallback store
314
+ // rather than in no store at all.
315
+ if (outcome.armAlert)
316
+ appendAlert(/** @type {string} */ (outcome.stderr), sessionId);
310
317
  } finally {
311
318
  reportSlowHook(
312
319
  HOOK_NAME,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.43.12",
3
+ "version": "2.44.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -326,13 +326,21 @@ export function configureHookgateMarker(path: string | null): void;
326
326
  */
327
327
  export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
328
328
  /**
329
- * Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
330
- * probes liveness without signalling: it throws ESRCH once the process is gone (a
331
- * killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
332
- * ours (still alive). An unreadable / not-yet-written marker is treated as alive —
333
- * favouring a brief wait over a premature give-up during setup's write race. A null
334
- * markerPath (no project dir → no setup to wait on) reads as alive so the caller's
335
- * own grace/ceiling bound governs.
329
+ * Is the setup process that wrote `markerPath` still alive?
330
+ *
331
+ * A marker declaring {@link SETUP_LOCK_DECLARATION} is judged by the LOCK, and that
332
+ * answer has no aliasing: the kernel drops an flock the instant its holder dies, so
333
+ * held means alive and free means dead, with no third state and nothing to reuse. A
334
+ * marker that declares nothing carries only a pid, and `process.kill(pid, 0)` is all
335
+ * there is — it throws ESRCH once the process is gone (a killed setup → stale
336
+ * marker, so stop waiting) and EPERM when it exists but is not ours (still alive).
337
+ * That reading is the one this replaces where it can: a recycled pid reads as a live
338
+ * setup for as long as the caller's ceiling allows.
339
+ *
340
+ * An unreadable / not-yet-written marker is treated as alive — favouring a brief
341
+ * wait over a premature give-up during setup's write race. A null markerPath (no
342
+ * project dir → no setup to wait on) reads as alive so the caller's own
343
+ * grace/ceiling bound governs.
336
344
  * @param {string | null} markerPath
337
345
  * @returns {boolean}
338
346
  */
@@ -503,6 +511,24 @@ export class EmptyStdinError extends Error {
503
511
  * command the reader should actually run.
504
512
  */
505
513
  export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
514
+ /**
515
+ * The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
516
+ * it, so it lives here — beside the other shared identity these hooks agree on —
517
+ * rather than in whichever store happened to need it first.
518
+ */
519
+ export const PROJECT_DIR: string;
520
+ /** Short project digest keying this project's $TMPDIR store names. */
521
+ export const PROJECT_HASH: string;
522
+ /**
523
+ * The line a cold-start marker carries on its own to declare that its writer holds
524
+ * an exclusive `flock` on the marker file for the whole install.
525
+ *
526
+ * This is the marker's PROTOCOL, and the reason it is declared in the data rather
527
+ * than assumed: a reader cannot tell "the writer released the lock because it died"
528
+ * from "the writer never took one" by looking at a free lock, so only a marker that
529
+ * says it locks may be judged by the lock.
530
+ */
531
+ export const SETUP_LOCK_DECLARATION: "flock";
506
532
  /**
507
533
  * EVERY process-wide slot these helpers keep — the four a host can observe or
508
534
  * steer, so a second instance that adopts this object is steered in all four at