agent-sanitizer 2.45.0 → 2.45.2

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.
@@ -198,6 +198,7 @@ export async function runJudgeCli(
198
198
  payloadBytes,
199
199
  tool,
200
200
  cpuMs: timer.cpuMs(),
201
+ redactorMs: timer.redactorMs(),
201
202
  }),
202
203
  event,
203
204
  ),
@@ -214,6 +215,7 @@ export async function runJudgeCli(
214
215
  payloadBytes,
215
216
  tool,
216
217
  cpuMs: timer.cpuMs(),
218
+ redactorMs: timer.redactorMs(),
217
219
  });
218
220
  onError(err, input);
219
221
  }
@@ -9,12 +9,15 @@
9
9
  * before anyone traced it back here). A hook past the budget therefore says so
10
10
  * IN BAND, in the model's context, where it can be relayed to the operator.
11
11
  *
12
- * TWO numbers, because wall-clock alone cannot say whose cost it is: a hook on
12
+ * THREE numbers, because wall-clock alone cannot say whose cost it is: a hook on
13
13
  * a contended host waits far longer than it computes (a 1.1 KB payload and a
14
14
  * 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
15
- * So the notice prints CPU beside the clock the share every affected call
16
- * repeats and never GATES on it: a hook wedged on a dead redactor socket
17
- * burns no CPU and is exactly the sanitizer's fault.
15
+ * So the notice prints, beside the clock, the CPU this process burned and the
16
+ * time it spent inside redactor round trips the daemon is a separate,
17
+ * long-lived process whose CPU this one cannot see (see {@link processCpuMs}),
18
+ * so the call that waits for it is the only measurable stand-in. The notice
19
+ * GATES on none of them: a hook wedged on a dead redactor socket burns no CPU
20
+ * and is exactly the sanitizer's fault.
18
21
  *
19
22
  * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
20
23
  * an install to the hook that merely waited it out would make the FIRST call of
@@ -90,13 +93,15 @@ export function formatBytes(bytes) {
90
93
  * that made this specific latency report take a manual multi-step
91
94
  * investigation to characterize (which tool call, how large a payload) before
92
95
  * anyone could act on it.
93
- * `cpuMs` is the run's own processor time (see {@link startHookTimer}); absent
94
- * when the caller has no way to measure it, which is what the shell port of
96
+ * `cpuMs` is the run's own processor time and `redactorMs` the wall-clock it
97
+ * spent inside redactor round trips (both from {@link startHookTimer}); absent
98
+ * when the caller has no way to measure them, which is what the shell port of
95
99
  * this module reports.
96
100
  * @typedef {{
97
101
  * payloadBytes?: number | null,
98
102
  * tool?: string | null,
99
103
  * cpuMs?: number | null,
104
+ * redactorMs?: number | null,
100
105
  * }} SlowHookContext
101
106
  */
102
107
 
@@ -138,6 +143,36 @@ function processCpuMs() {
138
143
  let provisioningMs = 0;
139
144
  let provisioningCpuMs = 0;
140
145
 
146
+ // Process-wide total of wall-clock spent inside redactor round trips. Wall-clock
147
+ // and not CPU because the daemon is a separate process: its work is invisible to
148
+ // processCpuMs, so how long this side waited for an answer is the only
149
+ // measurement available.
150
+ let redactorRoundTripMs = 0;
151
+
152
+ /**
153
+ * Run `work` — one redactor round trip — charging its duration to the redactor
154
+ * share, so a run that spent its second inside a redaction call is told apart
155
+ * from one that spent it anywhere else. Charged in a `finally`, since a round
156
+ * trip that THROWS (a stall that hit its deadline) is the one that spent the
157
+ * most.
158
+ *
159
+ * Unlike {@link excludeProvisioning} this only attributes; the time stays in the
160
+ * hook's wall-clock, because a slow redaction is a per-call cost the user waits
161
+ * for.
162
+ * @template T
163
+ * @param {() => Promise<T>} work
164
+ * @param {() => number} [now] injectable clock, for tests
165
+ * @returns {Promise<T>}
166
+ */
167
+ export async function chargeRedactorRoundTrip(work, now = Date.now) {
168
+ const started = now();
169
+ try {
170
+ return await work();
171
+ } finally {
172
+ redactorRoundTripMs += Math.max(0, now() - started);
173
+ }
174
+ }
175
+
141
176
  /**
142
177
  * Run `work`, charging its whole duration to provisioning so no timer running
143
178
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -177,24 +212,27 @@ export async function excludeProvisioning(
177
212
  * so far MINUS any provisioning charged in the meantime, and may be called more
178
213
  * than once.
179
214
  *
180
- * `wallMs` is what the user waited and `cpuMs` is what this process actually
181
- * computed. Both are needed to say whose cost a slow run is — see the module
182
- * header for the report that read a contended host as a sanitizer bug.
215
+ * `wallMs` is what the user waited, `cpuMs` is what this process actually
216
+ * computed, and `redactorMs` is what it spent inside redactor round trips
217
+ * ({@link chargeRedactorRoundTrip}). All three are needed to say where a slow
218
+ * run's time went — see the module header for the report that read a contended
219
+ * host as a sanitizer bug.
183
220
  *
184
- * Only provisioning charged since this timer started is subtracted, so an
185
- * earlier run's cold start cannot pay down a later run's real cost. A
186
- * provisioning window that straddles the timer's start would otherwise be able
187
- * to subtract more than the timer has measured, so both results are floored
188
- * at 0.
221
+ * Every reader counts only what was charged since this timer started, so an
222
+ * earlier run's cold start cannot pay down a later run's real cost, and an
223
+ * earlier run's round trip cannot be blamed on this one. A provisioning window
224
+ * that straddles the timer's start would otherwise be able to subtract more than
225
+ * the timer has measured, so the results are floored at 0.
189
226
  * @param {() => number} [now] injectable clock, for tests
190
227
  * @param {() => number} [cpuNow] injectable CPU clock, for tests
191
- * @returns {{ wallMs: () => number, cpuMs: () => number }}
228
+ * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
192
229
  */
193
230
  export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
194
231
  const started = now();
195
232
  const cpuStarted = cpuNow();
196
233
  const provisionedBefore = provisioningMs;
197
234
  const provisionedCpuBefore = provisioningCpuMs;
235
+ const redactorBefore = redactorRoundTripMs;
198
236
  return {
199
237
  wallMs: () =>
200
238
  Math.max(0, now() - started - (provisioningMs - provisionedBefore)),
@@ -203,9 +241,47 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
203
241
  0,
204
242
  cpuNow() - cpuStarted - (provisioningCpuMs - provisionedCpuBefore),
205
243
  ),
244
+ redactorMs: () => Math.max(0, redactorRoundTripMs - redactorBefore),
206
245
  };
207
246
  }
208
247
 
248
+ /**
249
+ * The attribution sentence for a run whose CPU and redactor-round-trip shares
250
+ * are both known: the two numbers, then which of the three WINDOWS the time went
251
+ * into — this hook computing, the redactor call, or neither.
252
+ *
253
+ * A window, not a culprit. The round trip is wall-clock measured from this side,
254
+ * so it holds the daemon's scan AND whatever descheduling the host imposed on
255
+ * either end; claiming the daemon from it would re-commit, one bucket over, the
256
+ * overreach this whole split exists to retract. Separating those two needs
257
+ * telemetry from inside the daemon, and the wire protocol has no place to carry
258
+ * it — the response is `handle_request`'s object or a bare JSON `null`, which no
259
+ * sibling field can ride on. So the redactor verdict says WHERE and declines to
260
+ * say WHOSE, while the other two windows, which no host load can move time into,
261
+ * are named outright.
262
+ *
263
+ * The CPU and redactor shares overlap by the framing this side does
264
+ * mid-round-trip, so they do not sum to the elapsed time; a share that dominates
265
+ * despite the overlap is still the one to act on.
266
+ * @param {number} elapsedMs
267
+ * @param {number} cpuMs
268
+ * @param {number} redactorMs
269
+ * @returns {string}
270
+ */
271
+ function attributeWait(elapsedMs, cpuMs, redactorMs) {
272
+ const otherMs = Math.max(0, elapsedMs - cpuMs - redactorMs);
273
+ const verdict =
274
+ redactorMs >= cpuMs && redactorMs >= otherMs
275
+ ? "The largest share was spent inside the redactor round trip — the daemon's scan, the host it shares, or both; this hook was not computing it."
276
+ : cpuMs >= otherMs
277
+ ? "The largest share is this hook computing — a per-call cost the sanitizer owns, repeated by every affected call."
278
+ : "The largest share is neither the redactor nor this hook computing: it was blocked on a loaded machine or on something outside the sanitizer that it called.";
279
+ return (
280
+ `, of which ${formatSeconds(cpuMs)}s was this hook's own CPU and ` +
281
+ `${formatSeconds(redactorMs)}s was inside redactor round trips. ${verdict}`
282
+ );
283
+ }
284
+
209
285
  /**
210
286
  * The model-facing line for a hook that overran the budget, or null when it did
211
287
  * not. Addressed to the model because the model is the only party that reliably
@@ -213,14 +289,16 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
213
289
  * is asked to relay the numbers, since the operator is the one who can file it.
214
290
  *
215
291
  * With `context.cpuMs` in hand the line says which share of the wait was the
216
- * sanitizer computing. Without it the line says that it cannot tell, rather
217
- * than asserting an attribution nothing measured: a wall-clock overrun on a
218
- * loaded host is the common case, and blaming it on the sanitizer sends the
219
- * operator hunting a per-call cost that does not exist.
292
+ * sanitizer computing, and with `context.redactorMs` too it names the window the
293
+ * time went into ({@link attributeWait}). Without them the line says that it cannot
294
+ * tell, rather than asserting an attribution nothing measured: a wall-clock
295
+ * overrun on a loaded host is the common case, and blaming it on the sanitizer
296
+ * sends the operator hunting a per-call cost that does not exist.
220
297
  *
221
- * The wait clause names candidates and picks none, for the same reason. A hook
222
- * that blocks on a dead socket inside a HOST extension spends no CPU and no
223
- * machine load, so naming either as the cause would be a second wrong guess.
298
+ * A CPU figure alone cannot pick a cause, so with only that the clause names
299
+ * candidates and commits to none. A hook that blocks on a dead socket inside a
300
+ * HOST extension spends no CPU and adds no machine load, so naming either as the
301
+ * cause would be a second wrong guess.
224
302
  * @param {string} hookName
225
303
  * @param {number} elapsedMs
226
304
  * @param {number} [thresholdMs]
@@ -237,16 +315,24 @@ export function slowHookNotice(
237
315
  ) {
238
316
  if (elapsedMs <= thresholdMs) return null;
239
317
  const cpuMs = context?.cpuMs;
240
- const attribution =
241
- typeof cpuMs === "number"
318
+ const redactorMs = context?.redactorMs;
319
+ const attributed =
320
+ typeof cpuMs === "number" && typeof redactorMs === "number";
321
+ const attribution = attributed
322
+ ? attributeWait(elapsedMs, cpuMs, redactorMs)
323
+ : typeof cpuMs === "number"
242
324
  ? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
243
325
  "Only the CPU share is work every affected call repeats; the rest was spent waiting, on a busy machine or on something this hook called."
244
326
  : ". Wall-clock alone cannot separate the sanitizer's own work from a busy machine.";
327
+ const timings = attributed
328
+ ? "all three timings"
329
+ : typeof cpuMs === "number"
330
+ ? "both timings"
331
+ : "timing";
245
332
  return (
246
333
  `agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
247
334
  `${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
248
- `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ` +
249
- `${typeof cpuMs === "number" ? "both timings" : "timing"}.`
335
+ `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ${timings}.`
250
336
  );
251
337
  }
252
338
 
@@ -18,7 +18,10 @@
18
18
  * daemon could not vet input.
19
19
  */
20
20
  import { spawn } from "node:child_process";
21
- import { excludeProvisioning } from "./hook-timing.mjs";
21
+ import {
22
+ chargeRedactorRoundTrip,
23
+ excludeProvisioning,
24
+ } from "./hook-timing.mjs";
22
25
  import { existsSync, lstatSync } from "node:fs";
23
26
  import { createConnection } from "node:net";
24
27
  import { tmpdir, userInfo } from "node:os";
@@ -435,9 +438,9 @@ export async function redactViaDaemon(text, opts = {}) {
435
438
  connect = connectAndRequest,
436
439
  spawn: spawnFn = spawnDaemon,
437
440
  waitForSocket: waitFn = waitForSocket,
438
- // The clock the provisioning charge is measured on; injectable alongside the
439
- // waitForSocket seam it brackets, since a stubbed wait advances a test clock
440
- // rather than real time.
441
+ // The clock the provisioning and round-trip charges are measured on;
442
+ // injectable alongside the seams they bracket, since a stubbed wait or
443
+ // connect advances a test clock rather than real time.
441
444
  now = Date.now,
442
445
  } = opts;
443
446
  // Remaining shared budget in ms, or undefined when no budget was threaded (the
@@ -487,9 +490,20 @@ export async function redactViaDaemon(text, opts = {}) {
487
490
  );
488
491
  return result;
489
492
  };
493
+ // Every dial is charged to the redactor share, so the slow-hook notice can
494
+ // place the wait in this call rather than guess at it: the daemon is a
495
+ // separate process, so its cost appears in neither this process's
496
+ // getrusage(RUSAGE_SELF) nor — being long-lived and never reaped here — in
497
+ // RUSAGE_CHILDREN. The round trip is the only measurable stand-in.
498
+ /** @param {number | undefined} deadlineMs @returns {Promise<RedactResponse|null>} */
499
+ const dial = (deadlineMs) =>
500
+ chargeRedactorRoundTrip(
501
+ () => connect(socketPath, request, deadlineMs),
502
+ now,
503
+ );
490
504
  try {
491
505
  // undefined remaining → connectAndRequest's own default request deadline.
492
- return validate(await connect(socketPath, request, remainingMs()));
506
+ return validate(await dial(remainingMs()));
493
507
  } catch (err) {
494
508
  if (!isRespawnable(err)) throw failClosed(err);
495
509
  // Socket absent or dead: (re)spawn the daemon, wait for it, retry exactly once
@@ -520,7 +534,7 @@ export async function redactViaDaemon(text, opts = {}) {
520
534
  );
521
535
  if (budgetSpent()) throw outOfBudget("after redactor respawn");
522
536
  try {
523
- return validate(await connect(socketPath, request, remainingMs()));
537
+ return validate(await dial(remainingMs()));
524
538
  } catch (err2) {
525
539
  throw failClosed(err2);
526
540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.45.0",
3
+ "version": "2.45.2",
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": {
@@ -21,6 +21,22 @@ export function formatSeconds(ms: number): string;
21
21
  * @returns {string}
22
22
  */
23
23
  export function formatBytes(bytes: number): string;
24
+ /**
25
+ * Run `work` — one redactor round trip — charging its duration to the redactor
26
+ * share, so a run that spent its second inside a redaction call is told apart
27
+ * from one that spent it anywhere else. Charged in a `finally`, since a round
28
+ * trip that THROWS (a stall that hit its deadline) is the one that spent the
29
+ * most.
30
+ *
31
+ * Unlike {@link excludeProvisioning} this only attributes; the time stays in the
32
+ * hook's wall-clock, because a slow redaction is a per-call cost the user waits
33
+ * for.
34
+ * @template T
35
+ * @param {() => Promise<T>} work
36
+ * @param {() => number} [now] injectable clock, for tests
37
+ * @returns {Promise<T>}
38
+ */
39
+ export function chargeRedactorRoundTrip<T>(work: () => Promise<T>, now?: () => number): Promise<T>;
24
40
  /**
25
41
  * Run `work`, charging its whole duration to provisioning so no timer running
26
42
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -46,22 +62,25 @@ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => numbe
46
62
  * so far MINUS any provisioning charged in the meantime, and may be called more
47
63
  * than once.
48
64
  *
49
- * `wallMs` is what the user waited and `cpuMs` is what this process actually
50
- * computed. Both are needed to say whose cost a slow run is — see the module
51
- * header for the report that read a contended host as a sanitizer bug.
52
- *
53
- * Only provisioning charged since this timer started is subtracted, so an
54
- * earlier run's cold start cannot pay down a later run's real cost. A
55
- * provisioning window that straddles the timer's start would otherwise be able
56
- * to subtract more than the timer has measured, so both results are floored
57
- * at 0.
65
+ * `wallMs` is what the user waited, `cpuMs` is what this process actually
66
+ * computed, and `redactorMs` is what it spent inside redactor round trips
67
+ * ({@link chargeRedactorRoundTrip}). All three are needed to say where a slow
68
+ * run's time went — see the module header for the report that read a contended
69
+ * host as a sanitizer bug.
70
+ *
71
+ * Every reader counts only what was charged since this timer started, so an
72
+ * earlier run's cold start cannot pay down a later run's real cost, and an
73
+ * earlier run's round trip cannot be blamed on this one. A provisioning window
74
+ * that straddles the timer's start would otherwise be able to subtract more than
75
+ * the timer has measured, so the results are floored at 0.
58
76
  * @param {() => number} [now] injectable clock, for tests
59
77
  * @param {() => number} [cpuNow] injectable CPU clock, for tests
60
- * @returns {{ wallMs: () => number, cpuMs: () => number }}
78
+ * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
61
79
  */
62
80
  export function startHookTimer(now?: () => number, cpuNow?: () => number): {
63
81
  wallMs: () => number;
64
82
  cpuMs: () => number;
83
+ redactorMs: () => number;
65
84
  };
66
85
  /**
67
86
  * The model-facing line for a hook that overran the budget, or null when it did
@@ -70,14 +89,16 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
70
89
  * is asked to relay the numbers, since the operator is the one who can file it.
71
90
  *
72
91
  * With `context.cpuMs` in hand the line says which share of the wait was the
73
- * sanitizer computing. Without it the line says that it cannot tell, rather
74
- * than asserting an attribution nothing measured: a wall-clock overrun on a
75
- * loaded host is the common case, and blaming it on the sanitizer sends the
76
- * operator hunting a per-call cost that does not exist.
77
- *
78
- * The wait clause names candidates and picks none, for the same reason. A hook
79
- * that blocks on a dead socket inside a HOST extension spends no CPU and no
80
- * machine load, so naming either as the cause would be a second wrong guess.
92
+ * sanitizer computing, and with `context.redactorMs` too it names the window the
93
+ * time went into ({@link attributeWait}). Without them the line says that it cannot
94
+ * tell, rather than asserting an attribution nothing measured: a wall-clock
95
+ * overrun on a loaded host is the common case, and blaming it on the sanitizer
96
+ * sends the operator hunting a per-call cost that does not exist.
97
+ *
98
+ * A CPU figure alone cannot pick a cause, so with only that the clause names
99
+ * candidates and commits to none. A hook that blocks on a dead socket inside a
100
+ * HOST extension spends no CPU and adds no machine load, so naming either as the
101
+ * cause would be a second wrong guess.
81
102
  * @param {string} hookName
82
103
  * @param {number} elapsedMs
83
104
  * @param {number} [thresholdMs]
@@ -174,12 +195,15 @@ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventNam
174
195
  * before anyone traced it back here). A hook past the budget therefore says so
175
196
  * IN BAND, in the model's context, where it can be relayed to the operator.
176
197
  *
177
- * TWO numbers, because wall-clock alone cannot say whose cost it is: a hook on
198
+ * THREE numbers, because wall-clock alone cannot say whose cost it is: a hook on
178
199
  * a contended host waits far longer than it computes (a 1.1 KB payload and a
179
200
  * 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
180
- * So the notice prints CPU beside the clock the share every affected call
181
- * repeats and never GATES on it: a hook wedged on a dead redactor socket
182
- * burns no CPU and is exactly the sanitizer's fault.
201
+ * So the notice prints, beside the clock, the CPU this process burned and the
202
+ * time it spent inside redactor round trips the daemon is a separate,
203
+ * long-lived process whose CPU this one cannot see (see {@link processCpuMs}),
204
+ * so the call that waits for it is the only measurable stand-in. The notice
205
+ * GATES on none of them: a hook wedged on a dead redactor socket burns no CPU
206
+ * and is exactly the sanitizer's fault.
183
207
  *
184
208
  * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
185
209
  * an install to the hook that merely waited it out would make the FIRST call of
@@ -217,12 +241,14 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
217
241
  * that made this specific latency report take a manual multi-step
218
242
  * investigation to characterize (which tool call, how large a payload) before
219
243
  * anyone could act on it.
220
- * `cpuMs` is the run's own processor time (see {@link startHookTimer}); absent
221
- * when the caller has no way to measure it, which is what the shell port of
244
+ * `cpuMs` is the run's own processor time and `redactorMs` the wall-clock it
245
+ * spent inside redactor round trips (both from {@link startHookTimer}); absent
246
+ * when the caller has no way to measure them, which is what the shell port of
222
247
  * this module reports.
223
248
  */
224
249
  export type SlowHookContext = {
225
250
  payloadBytes?: number | null;
226
251
  tool?: string | null;
227
252
  cpuMs?: number | null;
253
+ redactorMs?: number | null;
228
254
  };