agent-sanitizer 2.58.1 → 2.58.3

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.
@@ -805,6 +805,51 @@ function markerLockHeld(markerPath) {
805
805
  return null;
806
806
  }
807
807
 
808
+ /**
809
+ * Is a setup process DEMONSTRABLY running right now?
810
+ *
811
+ * The strict twin of {@link probeSetupAlive}, and the two differ only in which
812
+ * way they fall when the evidence runs out. That one decides whether to keep
813
+ * WAITING for a dependency, so every ambiguity — an unreadable marker, an
814
+ * unparseable pid, no project dir — reads as alive: waiting a moment longer is
815
+ * cheap and giving up early fails a hook closed. This one decides whether to
816
+ * stop charging a caller for time it spent, so the same ambiguity must read as
817
+ * NOT running: an absence of evidence that the machine was busy is not evidence
818
+ * that it was, and discounting a wait on that basis would hide the very
819
+ * slowdowns the caller is measuring for.
820
+ *
821
+ * Positive evidence is one of two things: the setup lock is held (the kernel
822
+ * drops an flock the instant its holder dies, so held means running), or the
823
+ * marker's pid names a live process. A marker this uid does not own is not
824
+ * evidence about our setup at all.
825
+ * @param {string | null} markerPath
826
+ * @returns {boolean}
827
+ */
828
+ export function setupRunning(markerPath) {
829
+ if (!markerIsTrusted(markerPath)) return false;
830
+ let raw;
831
+ try {
832
+ raw = readFileSync(/** @type {string} */ (markerPath), "utf8");
833
+ } catch {
834
+ return false;
835
+ }
836
+ const lines = raw.split("\n").map((line) => line.trim());
837
+ if (lines.includes(SETUP_LOCK_DECLARATION)) {
838
+ const held = markerLockHeld(/** @type {string} */ (markerPath));
839
+ if (held !== null) return held;
840
+ }
841
+ const pid = parseInt(lines[0], 10);
842
+ if (!Number.isInteger(pid) || pid <= 0) return false;
843
+ try {
844
+ process.kill(pid, 0);
845
+ return true;
846
+ } catch (err) {
847
+ // EPERM: the pid exists but belongs to another uid. It is running, which is
848
+ // what this answers; whose it is is markerIsTrusted's question, asked above.
849
+ return /** @type {NodeJS.ErrnoException} */ (err).code === "EPERM";
850
+ }
851
+ }
852
+
808
853
  /**
809
854
  * Is the setup process that wrote `markerPath` still alive?
810
855
  *
@@ -27,7 +27,12 @@
27
27
  *
28
28
  * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
29
29
  * an install to the hook that merely waited it out would make the FIRST call of
30
- * every session cry wolf, which is the alert fatigue this notice fights.
30
+ * every session cry wolf, which is the alert fatigue this notice fights. A
31
+ * provisioning step running in ANOTHER process is excluded the same way and for
32
+ * the same reason ({@link excludeConcurrentProvisioning}) — a launch hook's
33
+ * install saturates the machine every other hook is sharing, and a wait spent
34
+ * inside a window the caller can show was provisioning is no more this hook's
35
+ * cost than its own install is.
31
36
  *
32
37
  * Dependency-free on purpose: everything imports this, including hook-io, so a
33
38
  * back-import would close a cycle. The one emitter it needs is passed in. The
@@ -63,6 +68,19 @@ export const SLOW_HOOK_THRESHOLD_MS = 1000;
63
68
  */
64
69
  export const SLOW_PROVISION_THRESHOLD_MS = 60000;
65
70
 
71
+ /**
72
+ * The longest window {@link excludeConcurrentProvisioning} will discount.
73
+ *
74
+ * Ten times the hook budget, because the discount's whole premise is that the
75
+ * hook's work is small and the machine is busy: an instruction scan is tens of
76
+ * milliseconds of work, so a wait this far past its budget is not a busy box any
77
+ * more, whatever else is installing. Past the ceiling the run is measured in
78
+ * full and reports — the founding case of this module is a SessionStart scan
79
+ * that blocked startup for 30 SECONDS, and a cold-start install running
80
+ * alongside it must not be what buys that silence.
81
+ */
82
+ export const CONCURRENT_PROVISION_CEILING_MS = 10 * SLOW_HOOK_THRESHOLD_MS;
83
+
66
84
  /** Where a reader is asked to send the timing. */
67
85
  const ISSUE_URL =
68
86
  "https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
@@ -408,6 +426,57 @@ export async function excludeProvisioning(
408
426
  }
409
427
  }
410
428
 
429
+ /**
430
+ * Run `work`, charging its duration to provisioning when a SESSION-LEVEL
431
+ * provisioning step was in flight for the whole of it — a neighbouring hook's
432
+ * dependency install, which saturates the machine this hook is only sharing.
433
+ * {@link excludeProvisioning} discounts the wait this process performs itself;
434
+ * this discounts the one it merely runs alongside.
435
+ *
436
+ * `setupAlive` is asked twice, before and after, and only a step alive at BOTH
437
+ * ends is charged. A step that started or finished mid-run leaves a window
438
+ * nothing here can apportion, and splitting it by guess would discount the
439
+ * hook's own work — so that run is measured in full and reports honestly.
440
+ *
441
+ * Wall-clock only, where {@link excludeProvisioning} charges CPU too: the
442
+ * install runs in ANOTHER process, so none of it lands in this one's CPU figure,
443
+ * and charging CPU here would discount the hook's own computing.
444
+ *
445
+ * Bounded by {@link CONCURRENT_PROVISION_CEILING_MS}, which is what stops the
446
+ * discount from hiding a wedged run: a window past the ceiling is charged to
447
+ * nobody but the hook, however busy the machine was, because at that magnitude
448
+ * the hook is the thing that is broken.
449
+ * @template T
450
+ * @param {() => Promise<T>} work
451
+ * @param {() => boolean} setupAlive whether a session-level provisioning step
452
+ * is running right now; the caller owns the evidence (a cold-start marker and
453
+ * its PID), since this module reads no files of its own
454
+ * @param {() => number} [now] injectable clock, for tests
455
+ * @returns {Promise<T>}
456
+ */
457
+ export async function excludeConcurrentProvisioning(
458
+ work,
459
+ setupAlive,
460
+ now = Date.now,
461
+ ) {
462
+ const startedAlive = setupAlive();
463
+ const started = now();
464
+ try {
465
+ return await work();
466
+ } finally {
467
+ // In a `finally`, like every other charge here: a scan that THREW still
468
+ // waited out whatever the machine was doing, and the fault it reports is a
469
+ // separate matter from how long the wait was.
470
+ const elapsed = Math.max(0, now() - started);
471
+ if (
472
+ startedAlive &&
473
+ elapsed <= CONCURRENT_PROVISION_CEILING_MS &&
474
+ setupAlive()
475
+ )
476
+ provisioningMs += elapsed;
477
+ }
478
+ }
479
+
411
480
  /**
412
481
  * Start measuring; each reader on the returned object reports what has elapsed
413
482
  * so far MINUS any provisioning charged in the meantime, and may be called more
@@ -33,6 +33,7 @@ import {
33
33
  lazyImport,
34
34
  markerIsTrusted,
35
35
  probeSetupAlive,
36
+ setupRunning,
36
37
  PROJECT_DIR,
37
38
  readStdinJson,
38
39
  } from "./lib/hook-io.mjs";
@@ -52,7 +53,11 @@ import { formatReport } from "./lib/invisible-report.mjs";
52
53
  import { sweepStaleReveals } from "./lib/reveal.mjs";
53
54
  import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
54
55
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
55
- import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
56
+ import {
57
+ excludeConcurrentProvisioning,
58
+ reportSlowHook,
59
+ startHookTimer,
60
+ } from "./lib/hook-timing.mjs";
56
61
  // Relative, not the `agent-sanitizer` specifier every other engine import uses:
57
62
  // this is the scan's SCOPE, which is hook policy, and package.json's exports map
58
63
  // deliberately does not publish it — routing it through the specifier would fail
@@ -404,7 +409,13 @@ export async function cliMain(opts = {}) {
404
409
  // into a bug report (see lib/hook-timing.mjs).
405
410
  const timer = startHookTimer();
406
411
  try {
407
- await runScanCli(opts);
412
+ // Charged to provisioning when the session's setup is installing THROUGHOUT
413
+ // this run: that install saturates the machine, and a scan that merely
414
+ // waited it out has no per-call cost to report (see hook-timing.mjs).
415
+ await excludeConcurrentProvisioning(
416
+ () => runScanCli(opts),
417
+ () => setupRunning(hookgateMarkerPath()),
418
+ );
408
419
  } finally {
409
420
  reportSlowHook(
410
421
  HOOK_NAME,
@@ -21,12 +21,14 @@ import { readFileSync } from "node:fs";
21
21
  import {
22
22
  emitHookResponse,
23
23
  EmptyStdinError,
24
+ hookgateMarkerPath,
24
25
  HookEvent,
25
26
  isMain,
26
27
  lazyImport,
27
28
  PROJECT_DIR,
28
29
  readStdinJson,
29
30
  safeErrMessage,
31
+ setupRunning,
30
32
  } from "./lib/hook-io.mjs";
31
33
  import {
32
34
  registerFaultPolicy,
@@ -38,7 +40,11 @@ import {
38
40
  recordInstructionsLoaded,
39
41
  } from "./lib/invisible-alert.mjs";
40
42
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
41
- import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
43
+ import {
44
+ excludeConcurrentProvisioning,
45
+ reportSlowHook,
46
+ startHookTimer,
47
+ } from "./lib/hook-timing.mjs";
42
48
  import { formatReport } from "./lib/invisible-report.mjs";
43
49
  import {
44
50
  contextScopeContradiction,
@@ -259,6 +265,41 @@ export { HOOK_NAME };
259
265
  export async function cliMain({ trace: sink } = {}) {
260
266
  const timer = startHookTimer();
261
267
  const emitTrace = hookTrace(sink);
268
+ try {
269
+ // Charged to provisioning when the session's setup is installing THROUGHOUT
270
+ // this run: that install saturates the machine, and a scan that merely
271
+ // waited it out has no per-call cost to report (see hook-timing.mjs).
272
+ await excludeConcurrentProvisioning(
273
+ () => runLoadedScanCli(emitTrace),
274
+ () => setupRunning(hookgateMarkerPath()),
275
+ );
276
+ } finally {
277
+ reportSlowHook(
278
+ HOOK_NAME,
279
+ timer.wallMs(),
280
+ HookEvent.INSTRUCTIONS_LOADED,
281
+ emitHookResponse,
282
+ undefined,
283
+ // All four windows, including the two this scan normally leaves empty: a
284
+ // measured 0 rules a window OUT, where an omitted one leaves the notice
285
+ // naming candidates it cannot separate.
286
+ {
287
+ cpuMs: timer.cpuMs(),
288
+ redactorMs: timer.redactorMs(),
289
+ hostMs: timer.hostMs(),
290
+ },
291
+ );
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The scan itself. Split from {@link cliMain} so the timing wrapper above has a
297
+ * single call to bracket — every early return here is an exit that wrapper must
298
+ * still measure.
299
+ * @param {import("./lib/trace.mjs").TraceFn} emitTrace
300
+ * @returns {Promise<void>}
301
+ */
302
+ async function runLoadedScanCli(emitTrace) {
262
303
  /** @type {string | undefined} */
263
304
  let sessionId;
264
305
  try {
@@ -314,22 +355,6 @@ export async function cliMain({ trace: sink } = {}) {
314
355
  // rather than in no store at all.
315
356
  if (outcome.armAlert)
316
357
  appendAlert(/** @type {string} */ (outcome.stderr), sessionId);
317
- } finally {
318
- reportSlowHook(
319
- HOOK_NAME,
320
- timer.wallMs(),
321
- HookEvent.INSTRUCTIONS_LOADED,
322
- emitHookResponse,
323
- undefined,
324
- // All four windows, including the two this scan normally leaves empty: a
325
- // measured 0 rules a window OUT, where an omitted one leaves the notice
326
- // naming candidates it cannot separate.
327
- {
328
- cpuMs: timer.cpuMs(),
329
- redactorMs: timer.redactorMs(),
330
- hostMs: timer.hostMs(),
331
- },
332
- );
333
358
  }
334
359
  }
335
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.58.1",
3
+ "version": "2.58.3",
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": {
@@ -329,6 +329,27 @@ export function configureHookgateMarker(path: string | null): void;
329
329
  * @returns {string | null}
330
330
  */
331
331
  export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
332
+ /**
333
+ * Is a setup process DEMONSTRABLY running right now?
334
+ *
335
+ * The strict twin of {@link probeSetupAlive}, and the two differ only in which
336
+ * way they fall when the evidence runs out. That one decides whether to keep
337
+ * WAITING for a dependency, so every ambiguity — an unreadable marker, an
338
+ * unparseable pid, no project dir — reads as alive: waiting a moment longer is
339
+ * cheap and giving up early fails a hook closed. This one decides whether to
340
+ * stop charging a caller for time it spent, so the same ambiguity must read as
341
+ * NOT running: an absence of evidence that the machine was busy is not evidence
342
+ * that it was, and discounting a wait on that basis would hide the very
343
+ * slowdowns the caller is measuring for.
344
+ *
345
+ * Positive evidence is one of two things: the setup lock is held (the kernel
346
+ * drops an flock the instant its holder dies, so held means running), or the
347
+ * marker's pid names a live process. A marker this uid does not own is not
348
+ * evidence about our setup at all.
349
+ * @param {string | null} markerPath
350
+ * @returns {boolean}
351
+ */
352
+ export function setupRunning(markerPath: string | null): boolean;
332
353
  /**
333
354
  * Is the setup process that wrote `markerPath` still alive?
334
355
  *
@@ -104,6 +104,35 @@ export function chargeHostExtensionSync<T>(work: () => T, now?: () => number, cp
104
104
  * @returns {Promise<T>}
105
105
  */
106
106
  export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => number, cpuNow?: () => number): Promise<T>;
107
+ /**
108
+ * Run `work`, charging its duration to provisioning when a SESSION-LEVEL
109
+ * provisioning step was in flight for the whole of it — a neighbouring hook's
110
+ * dependency install, which saturates the machine this hook is only sharing.
111
+ * {@link excludeProvisioning} discounts the wait this process performs itself;
112
+ * this discounts the one it merely runs alongside.
113
+ *
114
+ * `setupAlive` is asked twice, before and after, and only a step alive at BOTH
115
+ * ends is charged. A step that started or finished mid-run leaves a window
116
+ * nothing here can apportion, and splitting it by guess would discount the
117
+ * hook's own work — so that run is measured in full and reports honestly.
118
+ *
119
+ * Wall-clock only, where {@link excludeProvisioning} charges CPU too: the
120
+ * install runs in ANOTHER process, so none of it lands in this one's CPU figure,
121
+ * and charging CPU here would discount the hook's own computing.
122
+ *
123
+ * Bounded by {@link CONCURRENT_PROVISION_CEILING_MS}, which is what stops the
124
+ * discount from hiding a wedged run: a window past the ceiling is charged to
125
+ * nobody but the hook, however busy the machine was, because at that magnitude
126
+ * the hook is the thing that is broken.
127
+ * @template T
128
+ * @param {() => Promise<T>} work
129
+ * @param {() => boolean} setupAlive whether a session-level provisioning step
130
+ * is running right now; the caller owns the evidence (a cold-start marker and
131
+ * its PID), since this module reads no files of its own
132
+ * @param {() => number} [now] injectable clock, for tests
133
+ * @returns {Promise<T>}
134
+ */
135
+ export function excludeConcurrentProvisioning<T>(work: () => Promise<T>, setupAlive: () => boolean, now?: () => number): Promise<T>;
107
136
  /**
108
137
  * Start measuring; each reader on the returned object reports what has elapsed
109
138
  * so far MINUS any provisioning charged in the meantime, and may be called more
@@ -262,6 +291,18 @@ export const SLOW_HOOK_THRESHOLD_MS: 1000;
262
291
  * re-provisioning every session), which is worth saying out loud.
263
292
  */
264
293
  export const SLOW_PROVISION_THRESHOLD_MS: 60000;
294
+ /**
295
+ * The longest window {@link excludeConcurrentProvisioning} will discount.
296
+ *
297
+ * Ten times the hook budget, because the discount's whole premise is that the
298
+ * hook's work is small and the machine is busy: an instruction scan is tens of
299
+ * milliseconds of work, so a wait this far past its budget is not a busy box any
300
+ * more, whatever else is installing. Past the ceiling the run is measured in
301
+ * full and reports — the founding case of this module is a SessionStart scan
302
+ * that blocked startup for 30 SECONDS, and a cold-start install running
303
+ * alongside it must not be what buys that silence.
304
+ */
305
+ export const CONCURRENT_PROVISION_CEILING_MS: number;
265
306
  /**
266
307
  * Debugging context a caller may already have in hand when a hook overruns its
267
308
  * budget, so the notice names WHAT was slow instead of just HOW slow — the gap