agent-sanitizer 2.50.0 → 2.51.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
@@ -289,6 +289,7 @@ singleton, and two copies in one bundle double-fire the inlined CLIs.
289
289
  | `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
290
290
  | `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
291
291
  | `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
292
+ | `claude-hooks/lib/hook-timing` | The shared slow-hook budget, its measured windows and the notice they compose |
292
293
 
293
294
  Only `plugin-hooks` itself is unexported under its own name — it is reachable as
294
295
  the bare `claude-hooks` entry above.
@@ -199,6 +199,7 @@ export async function runJudgeCli(
199
199
  tool,
200
200
  cpuMs: timer.cpuMs(),
201
201
  redactorMs: timer.redactorMs(),
202
+ hostMs: timer.hostMs(),
202
203
  }),
203
204
  event,
204
205
  ),
@@ -216,6 +217,7 @@ export async function runJudgeCli(
216
217
  tool,
217
218
  cpuMs: timer.cpuMs(),
218
219
  redactorMs: timer.redactorMs(),
220
+ hostMs: timer.hostMs(),
219
221
  });
220
222
  onError(err, input);
221
223
  }
@@ -9,15 +9,21 @@
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
- * THREE numbers, because wall-clock alone cannot say whose cost it is: a hook on
12
+ * FOUR 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, 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.
15
+ * So the notice prints, beside the clock, the CPU this process burned, the time
16
+ * it spent inside redactor round trips, and the time it spent inside a HOST
17
+ * EXTENSION it called ({@link chargeHostExtension}) the redactor daemon and a
18
+ * host callback's subprocess or socket peer are separate processes whose CPU this
19
+ * one cannot see (see {@link processCpuMs}), so the call that waits for each is
20
+ * the only measurable stand-in. The notice GATES on none of them: a hook wedged
21
+ * on a dead redactor socket burns no CPU and is exactly the sanitizer's fault.
22
+ *
23
+ * The host-extension window is what turned "blocked on something outside the
24
+ * sanitizer" — a verdict nobody can act on — into a named callee: a composer's
25
+ * best-effort audit POST to an unreachable sink charged every tool call its full
26
+ * 1.0s connect bound, and the notice could name none of it.
21
27
  *
22
28
  * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
23
29
  * an install to the hook that merely waited it out would make the FIRST call of
@@ -93,15 +99,16 @@ export function formatBytes(bytes) {
93
99
  * that made this specific latency report take a manual multi-step
94
100
  * investigation to characterize (which tool call, how large a payload) before
95
101
  * anyone could act on it.
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
99
- * this module reports.
102
+ * `cpuMs` is the run's own processor time, `redactorMs` the wall-clock it spent
103
+ * inside redactor round trips, and `hostMs` the wall-clock it spent inside host
104
+ * extensions (all from {@link startHookTimer}); absent when the caller has no way
105
+ * to measure them, which is what the shell port of this module reports.
100
106
  * @typedef {{
101
107
  * payloadBytes?: number | null,
102
108
  * tool?: string | null,
103
109
  * cpuMs?: number | null,
104
110
  * redactorMs?: number | null,
111
+ * hostMs?: number | null,
105
112
  * }} SlowHookContext
106
113
  */
107
114
 
@@ -149,6 +156,20 @@ let provisioningCpuMs = 0;
149
156
  // measurement available.
150
157
  let redactorRoundTripMs = 0;
151
158
 
159
+ // Process-wide total of wall-clock spent inside host extensions. Same reason as
160
+ // the redactor total above: a callback's cost lands in a subprocess or a socket
161
+ // peer, so this side's wait is the only measurement available. Its CPU is tracked
162
+ // beside it because a callback that computes IN THIS PROCESS shows up in
163
+ // processCpuMs too, and the notice must not charge one second of work to two
164
+ // windows; startHookTimer subtracts this from the hook's own CPU figure.
165
+ let hostExtensionMs = 0;
166
+ let hostExtensionCpuMs = 0;
167
+ // How many host-extension brackets are open. Only the OUTERMOST charges: the
168
+ // package brackets `postText` and `audit` itself AND publishes the charger, so a
169
+ // composer that charges its own work inside one of those callbacks would
170
+ // otherwise add the same interval twice and report a 3s callback as 6s.
171
+ let hostExtensionDepth = 0;
172
+
152
173
  /**
153
174
  * Run `work` — one redactor round trip — charging its duration to the redactor
154
175
  * share, so a run that spent its second inside a redaction call is told apart
@@ -173,6 +194,84 @@ export async function chargeRedactorRoundTrip(work, now = Date.now) {
173
194
  }
174
195
  }
175
196
 
197
+ /**
198
+ * Run `work` — one call into a HOST EXTENSION (a composer's `postText`, `audit`
199
+ * or other injected callback) — charging its duration to the host share, so a run
200
+ * that spent its second inside a callback is told apart from one that spent it
201
+ * anywhere else. Charged in a `finally`, since a callback that THROWS is the one
202
+ * that spent the most.
203
+ *
204
+ * Like {@link chargeRedactorRoundTrip} this only attributes; the time stays in
205
+ * the hook's wall-clock, because the user waits for it either way. It says WHERE
206
+ * the time went and declines to say WHOSE: the wait holds the callback's own work
207
+ * AND whatever descheduling the host imposed on it.
208
+ *
209
+ * `work` may be synchronous — a callback that spawns a subprocess and blocks is
210
+ * charged in full, because the whole call runs inside this bracket.
211
+ *
212
+ * Reach this through `claude-hooks/sanitize-output`'s re-export whenever the hook
213
+ * is the bundled copy: importing this subpath separately yields a SECOND module
214
+ * instance whose total no timer reads, and the notice then reports a measured
215
+ * `0.0s` for a window that really burned seconds.
216
+ * @template T
217
+ * @param {() => Promise<T> | T} work
218
+ * @param {() => number} [now] injectable clock, for tests
219
+ * @returns {Promise<T>}
220
+ */
221
+ export async function chargeHostExtension(
222
+ work,
223
+ now = Date.now,
224
+ cpuNow = processCpuMs,
225
+ ) {
226
+ const outermost = hostExtensionDepth === 0;
227
+ const started = now();
228
+ const cpuStarted = cpuNow();
229
+ hostExtensionDepth += 1;
230
+ try {
231
+ return await work();
232
+ } finally {
233
+ hostExtensionDepth -= 1;
234
+ if (outermost) {
235
+ hostExtensionMs += Math.max(0, now() - started);
236
+ hostExtensionCpuMs += Math.max(0, cpuNow() - cpuStarted);
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * {@link chargeHostExtension} for a callback that must answer SYNCHRONOUSLY — the
243
+ * `redactNote` seam returns a string, so an async wrapper cannot stand in for it,
244
+ * and a composer that spawns a subprocess there would otherwise leave the wait in
245
+ * the unattributed remainder and its CPU charged to the sanitizer.
246
+ *
247
+ * Nesting and CPU are handled exactly as above, so the two chargers compose: a
248
+ * sync callback invoked inside an async bracket charges nothing twice.
249
+ * @template T
250
+ * @param {() => T} work
251
+ * @param {() => number} [now] injectable clock, for tests
252
+ * @param {() => number} [cpuNow] injectable CPU clock, for tests
253
+ * @returns {T}
254
+ */
255
+ export function chargeHostExtensionSync(
256
+ work,
257
+ now = Date.now,
258
+ cpuNow = processCpuMs,
259
+ ) {
260
+ const outermost = hostExtensionDepth === 0;
261
+ const started = now();
262
+ const cpuStarted = cpuNow();
263
+ hostExtensionDepth += 1;
264
+ try {
265
+ return work();
266
+ } finally {
267
+ hostExtensionDepth -= 1;
268
+ if (outermost) {
269
+ hostExtensionMs += Math.max(0, now() - started);
270
+ hostExtensionCpuMs += Math.max(0, cpuNow() - cpuStarted);
271
+ }
272
+ }
273
+ }
274
+
176
275
  /**
177
276
  * Run `work`, charging its whole duration to provisioning so no timer running
178
277
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -212,10 +311,11 @@ export async function excludeProvisioning(
212
311
  * so far MINUS any provisioning charged in the meantime, and may be called more
213
312
  * than once.
214
313
  *
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
314
+ * `wallMs` is what the user waited, `cpuMs` is what this process computed OUTSIDE
315
+ * a host callback, `redactorMs` is what it spent inside redactor round trips
316
+ * ({@link chargeRedactorRoundTrip}) and `hostMs` what it spent inside host
317
+ * extensions ({@link chargeHostExtension}). All four are needed to say where a
318
+ * slow run's time went — see the module header for the report that read a contended
219
319
  * host as a sanitizer bug.
220
320
  *
221
321
  * Every reader counts only what was charged since this timer started, so an
@@ -225,7 +325,7 @@ export async function excludeProvisioning(
225
325
  * the timer has measured, so the results are floored at 0.
226
326
  * @param {() => number} [now] injectable clock, for tests
227
327
  * @param {() => number} [cpuNow] injectable CPU clock, for tests
228
- * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
328
+ * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number, hostMs: () => number }}
229
329
  */
230
330
  export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
231
331
  const started = now();
@@ -233,22 +333,33 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
233
333
  const provisionedBefore = provisioningMs;
234
334
  const provisionedCpuBefore = provisioningCpuMs;
235
335
  const redactorBefore = redactorRoundTripMs;
336
+ const hostBefore = hostExtensionMs;
337
+ const hostCpuBefore = hostExtensionCpuMs;
236
338
  return {
237
339
  wallMs: () =>
238
340
  Math.max(0, now() - started - (provisioningMs - provisionedBefore)),
341
+ // Host-extension CPU is subtracted alongside provisioning's, so this figure is
342
+ // the SANITIZER's own work: a callback that computes in this process would
343
+ // otherwise be charged to the hook and to the host window both, and the
344
+ // largest-share verdict would name the sanitizer for the composer's cost.
239
345
  cpuMs: () =>
240
346
  Math.max(
241
347
  0,
242
- cpuNow() - cpuStarted - (provisioningCpuMs - provisionedCpuBefore),
348
+ cpuNow() -
349
+ cpuStarted -
350
+ (provisioningCpuMs - provisionedCpuBefore) -
351
+ (hostExtensionCpuMs - hostCpuBefore),
243
352
  ),
244
353
  redactorMs: () => Math.max(0, redactorRoundTripMs - redactorBefore),
354
+ hostMs: () => Math.max(0, hostExtensionMs - hostBefore),
245
355
  };
246
356
  }
247
357
 
248
358
  /**
249
359
  * 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.
360
+ * are both known: the three numbers, then which of the four WINDOWS the time went
361
+ * into — this hook computing, the redactor call, a host extension, or none of
362
+ * them.
252
363
  *
253
364
  * A window, not a culprit. The round trip is wall-clock measured from this side,
254
365
  * so it holds the daemon's scan AND whatever descheduling the host imposed on
@@ -260,25 +371,46 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
260
371
  * say WHOSE, while the other two windows, which no host load can move time into,
261
372
  * are named outright.
262
373
  *
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.
374
+ * The host-extension window is named the same way and for the same reason: the
375
+ * callback is the composer's code, so a wait inside it is a cost the COMPOSER
376
+ * owns, and naming it is what lets a reader look at the right repository. A
377
+ * composer's audit POST to an absent sink is the case that motivated it.
378
+ *
379
+ * The CPU, redactor and host shares overlap by the framing this side does
380
+ * mid-round-trip and mid-callback, so they do not sum to the elapsed time; a
381
+ * share that dominates despite the overlap is still the one to act on.
266
382
  * @param {number} elapsedMs
267
383
  * @param {number} cpuMs
268
384
  * @param {number} redactorMs
385
+ * @param {number | undefined} hostMs absent when the caller measures no host
386
+ * callbacks; a caller that does pass 0 when none ran
269
387
  * @returns {string}
270
388
  */
271
- function attributeWait(elapsedMs, cpuMs, redactorMs) {
272
- const otherMs = Math.max(0, elapsedMs - cpuMs - redactorMs);
389
+ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
390
+ // `hostMs` absent means the caller cannot measure that window, not that it was
391
+ // empty — a caller that CAN measure passes 0 and gets the zero printed. So an
392
+ // absent one is left out of both the sentence and the remainder, and the
393
+ // verdict's fourth arm names it as one of the unmeasured candidates.
394
+ const measuredHostMs = hostMs ?? 0;
395
+ const otherMs = Math.max(0, elapsedMs - cpuMs - redactorMs - measuredHostMs);
396
+ const largest = Math.max(cpuMs, redactorMs, measuredHostMs, otherMs);
273
397
  const verdict =
274
- redactorMs >= cpuMs && redactorMs >= otherMs
398
+ redactorMs === largest
275
399
  ? "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.";
400
+ : hostMs !== undefined && hostMs === largest
401
+ ? "The largest share was spent inside a host extension this hook called — a callback the composer injected, and a cost that composer owns; neither the sanitizer nor the redactor was computing it."
402
+ : cpuMs === largest
403
+ ? "The largest share is this hook computing — a per-call cost the sanitizer owns, repeated by every affected call."
404
+ : hostMs === undefined
405
+ ? "The largest share is neither the redactor nor this hook computing: it was blocked on a loaded machine, on a host extension this caller does not measure, or on something else outside the sanitizer that it called."
406
+ : "The largest share is none of those three: it was blocked on a loaded machine or on something outside the sanitizer that it called without measuring.";
407
+ const hostClause =
408
+ hostMs === undefined
409
+ ? ""
410
+ : ` and ${formatSeconds(hostMs)}s was inside host extensions`;
279
411
  return (
280
- `, of which ${formatSeconds(cpuMs)}s was this hook's own CPU and ` +
281
- `${formatSeconds(redactorMs)}s was inside redactor round trips. ${verdict}`
412
+ `, of which ${formatSeconds(cpuMs)}s was this hook's own CPU${hostClause === "" ? " and" : ","} ` +
413
+ `${formatSeconds(redactorMs)}s was inside redactor round trips${hostClause}. ${verdict}`
282
414
  );
283
415
  }
284
416
 
@@ -290,7 +422,9 @@ function attributeWait(elapsedMs, cpuMs, redactorMs) {
290
422
  *
291
423
  * With `context.cpuMs` in hand the line says which share of the wait was the
292
424
  * 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
425
+ * time went into ({@link attributeWait}), including `context.hostMs`'s host
426
+ * extensions — a caller that measured the first two but has no extensions to
427
+ * charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
294
428
  * tell, rather than asserting an attribution nothing measured: a wall-clock
295
429
  * overrun on a loaded host is the common case, and blaming it on the sanitizer
296
430
  * sends the operator hunting a per-call cost that does not exist.
@@ -318,14 +452,21 @@ export function slowHookNotice(
318
452
  const redactorMs = context?.redactorMs;
319
453
  const attributed =
320
454
  typeof cpuMs === "number" && typeof redactorMs === "number";
455
+ // Left UNDEFINED when the caller passed none: a zero would claim a measurement
456
+ // nobody made. Every caller in this package charges the seams and passes the
457
+ // number, including when it is 0.
458
+ const hostMs =
459
+ typeof context?.hostMs === "number" ? context.hostMs : undefined;
321
460
  const attribution = attributed
322
- ? attributeWait(elapsedMs, cpuMs, redactorMs)
461
+ ? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs)
323
462
  : typeof cpuMs === "number"
324
463
  ? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
325
464
  "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."
326
465
  : ". Wall-clock alone cannot separate the sanitizer's own work from a busy machine.";
327
466
  const timings = attributed
328
- ? "all three timings"
467
+ ? hostMs === undefined
468
+ ? "all three timings"
469
+ : "all four timings"
329
470
  : typeof cpuMs === "number"
330
471
  ? "both timings"
331
472
  : "timing";
@@ -25,6 +25,15 @@
25
25
  * isUntrustedIngress).
26
26
  */
27
27
  import { redactViaDaemon, positiveMsOr } from "./lib/redactor-client.mjs";
28
+ import {
29
+ chargeHostExtension,
30
+ chargeHostExtensionSync,
31
+ } from "./lib/hook-timing.mjs";
32
+ // Re-exported for a composer whose own awaited work runs INSIDE this hook's
33
+ // judge (an audit POST, a policy call): charging it needs the same module
34
+ // instance the timer reads, and a bundled composer that imports the package
35
+ // subpath separately gets a second instance whose totals no timer sees.
36
+ export { chargeHostExtension, chargeHostExtensionSync };
28
37
  import {
29
38
  isMain,
30
39
  lazyImport,
@@ -284,8 +293,13 @@ export async function sanitizeText(
284
293
  if (!secrets) return null;
285
294
  // The note is derived from the PRE-redaction text: the caller's reason for
286
295
  // annotating (which variable, which provenance) is exactly what redaction
287
- // is about to remove.
288
- const note = ext.redactNote?.(content);
296
+ // is about to remove. Charged like the other seams — this one must answer
297
+ // synchronously, so a composer that blocks here (a `spawnSync` lookup)
298
+ // would otherwise leave its wait unattributed and its CPU on the hook.
299
+ const redactNote = ext.redactNote;
300
+ const note = redactNote
301
+ ? chargeHostExtensionSync(() => redactNote(content))
302
+ : undefined;
289
303
  return note
290
304
  ? { text: secrets.text, found: secrets.found, note }
291
305
  : { text: secrets.text, found: secrets.found };
@@ -298,16 +312,18 @@ export async function sanitizeText(
298
312
  // The one place the seam's shape is normalized, so nothing downstream has to
299
313
  // branch on an absent `notes` and the banner composer sees one shape.
300
314
  const result = { ...seamResult, notes: seamResult.notes ?? [] };
301
- return ext.postText
302
- ? applyPostText(
303
- result,
304
- await ext.postText(result.cleaned, {
305
- toolName,
306
- webIngress,
307
- deadline,
308
- }),
309
- )
310
- : result;
315
+ // Charged to the host-extension window: the callback is the composer's code and
316
+ // may block on a subprocess or a socket, neither of which this process's CPU
317
+ // figure can see, so an uncharged wait would land in the slow-hook notice's
318
+ // unattributed remainder and name nobody.
319
+ const postText = ext.postText;
320
+ if (!postText) return result;
321
+ return applyPostText(
322
+ result,
323
+ await chargeHostExtension(() =>
324
+ postText(result.cleaned, { toolName, webIngress, deadline }),
325
+ ),
326
+ );
311
327
  }
312
328
 
313
329
  /**
@@ -837,8 +853,17 @@ registerFaultPolicy(HOOK_NAME, {
837
853
  */
838
854
  export async function evaluateToolOutput(input, ext = {}) {
839
855
  // Best-effort, like the default sink: a host callback that throws must not be
840
- // the thing that suppresses a tool output (see bestEffortTrace).
841
- const emitTrace = bestEffortTrace(ext.trace ?? trace);
856
+ // the thing that suppresses a tool output (see bestEffortTrace). A COMPOSER's
857
+ // sink is charged to the host window — it may write over a socket this process
858
+ // cannot see the cost of — while the package's own sink is not, since that
859
+ // file write is the sanitizer's own work.
860
+ const hostTrace = ext.trace;
861
+ const emitTrace = bestEffortTrace(
862
+ hostTrace
863
+ ? (event, fields) =>
864
+ chargeHostExtensionSync(() => hostTrace(event, fields))
865
+ : trace,
866
+ );
842
867
  /**
843
868
  * @param {string} outcome noop | clean | flagged | modified
844
869
  * @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
@@ -1044,16 +1069,22 @@ export async function judgeSanitizeOutput(event, ext = {}) {
1044
1069
  // with silent holes is worse than a suppressed tool output.
1045
1070
  if (ext.audit && event.response !== null && event.response !== undefined) {
1046
1071
  const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
1047
- await ext.audit({
1048
- tool: event.tool,
1049
- // The session identity travels in `meta`, not alongside the tool fields, so
1050
- // a recorder filing one trail per session cannot reach it unless it is
1051
- // lifted here.
1052
- session_id: event.meta?.session_id,
1053
- modified,
1054
- output: modified ? fields?.mutated_output : event.response,
1055
- context: fields?.additional_context,
1056
- });
1072
+ // Charged to the host-extension window for the same reason as `postText`
1073
+ // above: a recorder that POSTs to an absent sink waits out its own connect
1074
+ // bound, and the notice must be able to name that window.
1075
+ const audit = ext.audit;
1076
+ await chargeHostExtension(() =>
1077
+ audit({
1078
+ tool: event.tool,
1079
+ // The session identity travels in `meta`, not alongside the tool fields,
1080
+ // so a recorder filing one trail per session cannot reach it unless it is
1081
+ // lifted here.
1082
+ session_id: event.meta?.session_id,
1083
+ modified,
1084
+ output: modified ? fields?.mutated_output : event.response,
1085
+ context: fields?.additional_context,
1086
+ }),
1087
+ );
1057
1088
  }
1058
1089
  /** @type {import("agent-control-plane-core").Verdict} */
1059
1090
  const verdict = { decision: Decision.ALLOW };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.50.0",
3
+ "version": "2.51.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": {
@@ -196,6 +196,10 @@
196
196
  "./claude-hooks/lib/trace": {
197
197
  "types": "./types/claude-hooks/lib/trace.d.mts",
198
198
  "default": "./claude-hooks/lib/trace.mjs"
199
+ },
200
+ "./claude-hooks/lib/hook-timing": {
201
+ "types": "./types/claude-hooks/lib/hook-timing.d.mts",
202
+ "default": "./claude-hooks/lib/hook-timing.mjs"
199
203
  }
200
204
  },
201
205
  "files": [
@@ -37,6 +37,46 @@ export function formatBytes(bytes: number): string;
37
37
  * @returns {Promise<T>}
38
38
  */
39
39
  export function chargeRedactorRoundTrip<T>(work: () => Promise<T>, now?: () => number): Promise<T>;
40
+ /**
41
+ * Run `work` — one call into a HOST EXTENSION (a composer's `postText`, `audit`
42
+ * or other injected callback) — charging its duration to the host share, so a run
43
+ * that spent its second inside a callback is told apart from one that spent it
44
+ * anywhere else. Charged in a `finally`, since a callback that THROWS is the one
45
+ * that spent the most.
46
+ *
47
+ * Like {@link chargeRedactorRoundTrip} this only attributes; the time stays in
48
+ * the hook's wall-clock, because the user waits for it either way. It says WHERE
49
+ * the time went and declines to say WHOSE: the wait holds the callback's own work
50
+ * AND whatever descheduling the host imposed on it.
51
+ *
52
+ * `work` may be synchronous — a callback that spawns a subprocess and blocks is
53
+ * charged in full, because the whole call runs inside this bracket.
54
+ *
55
+ * Reach this through `claude-hooks/sanitize-output`'s re-export whenever the hook
56
+ * is the bundled copy: importing this subpath separately yields a SECOND module
57
+ * instance whose total no timer reads, and the notice then reports a measured
58
+ * `0.0s` for a window that really burned seconds.
59
+ * @template T
60
+ * @param {() => Promise<T> | T} work
61
+ * @param {() => number} [now] injectable clock, for tests
62
+ * @returns {Promise<T>}
63
+ */
64
+ export function chargeHostExtension<T>(work: () => Promise<T> | T, now?: () => number, cpuNow?: typeof processCpuMs): Promise<T>;
65
+ /**
66
+ * {@link chargeHostExtension} for a callback that must answer SYNCHRONOUSLY — the
67
+ * `redactNote` seam returns a string, so an async wrapper cannot stand in for it,
68
+ * and a composer that spawns a subprocess there would otherwise leave the wait in
69
+ * the unattributed remainder and its CPU charged to the sanitizer.
70
+ *
71
+ * Nesting and CPU are handled exactly as above, so the two chargers compose: a
72
+ * sync callback invoked inside an async bracket charges nothing twice.
73
+ * @template T
74
+ * @param {() => T} work
75
+ * @param {() => number} [now] injectable clock, for tests
76
+ * @param {() => number} [cpuNow] injectable CPU clock, for tests
77
+ * @returns {T}
78
+ */
79
+ export function chargeHostExtensionSync<T>(work: () => T, now?: () => number, cpuNow?: () => number): T;
40
80
  /**
41
81
  * Run `work`, charging its whole duration to provisioning so no timer running
42
82
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -62,10 +102,11 @@ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => numbe
62
102
  * so far MINUS any provisioning charged in the meantime, and may be called more
63
103
  * than once.
64
104
  *
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
105
+ * `wallMs` is what the user waited, `cpuMs` is what this process computed OUTSIDE
106
+ * a host callback, `redactorMs` is what it spent inside redactor round trips
107
+ * ({@link chargeRedactorRoundTrip}) and `hostMs` what it spent inside host
108
+ * extensions ({@link chargeHostExtension}). All four are needed to say where a
109
+ * slow run's time went — see the module header for the report that read a contended
69
110
  * host as a sanitizer bug.
70
111
  *
71
112
  * Every reader counts only what was charged since this timer started, so an
@@ -75,12 +116,13 @@ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => numbe
75
116
  * the timer has measured, so the results are floored at 0.
76
117
  * @param {() => number} [now] injectable clock, for tests
77
118
  * @param {() => number} [cpuNow] injectable CPU clock, for tests
78
- * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
119
+ * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number, hostMs: () => number }}
79
120
  */
80
121
  export function startHookTimer(now?: () => number, cpuNow?: () => number): {
81
122
  wallMs: () => number;
82
123
  cpuMs: () => number;
83
124
  redactorMs: () => number;
125
+ hostMs: () => number;
84
126
  };
85
127
  /**
86
128
  * The model-facing line for a hook that overran the budget, or null when it did
@@ -90,7 +132,9 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
90
132
  *
91
133
  * With `context.cpuMs` in hand the line says which share of the wait was the
92
134
  * 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
135
+ * time went into ({@link attributeWait}), including `context.hostMs`'s host
136
+ * extensions — a caller that measured the first two but has no extensions to
137
+ * charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
94
138
  * tell, rather than asserting an attribution nothing measured: a wall-clock
95
139
  * overrun on a loaded host is the common case, and blaming it on the sanitizer
96
140
  * sends the operator hunting a per-call cost that does not exist.
@@ -195,15 +239,21 @@ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventNam
195
239
  * before anyone traced it back here). A hook past the budget therefore says so
196
240
  * IN BAND, in the model's context, where it can be relayed to the operator.
197
241
  *
198
- * THREE numbers, because wall-clock alone cannot say whose cost it is: a hook on
242
+ * FOUR numbers, because wall-clock alone cannot say whose cost it is: a hook on
199
243
  * a contended host waits far longer than it computes (a 1.1 KB payload and a
200
244
  * 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
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.
245
+ * So the notice prints, beside the clock, the CPU this process burned, the time
246
+ * it spent inside redactor round trips, and the time it spent inside a HOST
247
+ * EXTENSION it called ({@link chargeHostExtension}) the redactor daemon and a
248
+ * host callback's subprocess or socket peer are separate processes whose CPU this
249
+ * one cannot see (see {@link processCpuMs}), so the call that waits for each is
250
+ * the only measurable stand-in. The notice GATES on none of them: a hook wedged
251
+ * on a dead redactor socket burns no CPU and is exactly the sanitizer's fault.
252
+ *
253
+ * The host-extension window is what turned "blocked on something outside the
254
+ * sanitizer" — a verdict nobody can act on — into a named callee: a composer's
255
+ * best-effort audit POST to an unreachable sink charged every tool call its full
256
+ * 1.0s connect bound, and the notice could name none of it.
207
257
  *
208
258
  * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
209
259
  * an install to the hook that merely waited it out would make the FIRST call of
@@ -241,14 +291,26 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
241
291
  * that made this specific latency report take a manual multi-step
242
292
  * investigation to characterize (which tool call, how large a payload) before
243
293
  * anyone could act on it.
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
247
- * this module reports.
294
+ * `cpuMs` is the run's own processor time, `redactorMs` the wall-clock it spent
295
+ * inside redactor round trips, and `hostMs` the wall-clock it spent inside host
296
+ * extensions (all from {@link startHookTimer}); absent when the caller has no way
297
+ * to measure them, which is what the shell port of this module reports.
248
298
  */
249
299
  export type SlowHookContext = {
250
300
  payloadBytes?: number | null;
251
301
  tool?: string | null;
252
302
  cpuMs?: number | null;
253
303
  redactorMs?: number | null;
304
+ hostMs?: number | null;
254
305
  };
306
+ /**
307
+ * This process's own user+system processor time so far, in milliseconds.
308
+ *
309
+ * `process.cpuUsage()` is RUSAGE_SELF: it counts what this node process
310
+ * computed and excludes both idle waiting and any child process. That is
311
+ * exactly the split the notice needs — a hook blocked on a socket, a lock or a
312
+ * loaded scheduler adds wall-clock here and no CPU.
313
+ * @returns {number}
314
+ */
315
+ declare function processCpuMs(): number;
316
+ export {};
@@ -351,3 +351,6 @@ export type SanitizeExtensions = {
351
351
  */
352
352
  remedy?: string | undefined;
353
353
  };
354
+ import { chargeHostExtension } from "./lib/hook-timing.mjs";
355
+ import { chargeHostExtensionSync } from "./lib/hook-timing.mjs";
356
+ export { chargeHostExtension, chargeHostExtensionSync };