agent-sanitizer 2.37.4 → 2.38.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.
@@ -11,6 +11,7 @@ import {
11
11
  awaitLazyDependency,
12
12
  errMessage,
13
13
  hookgateMarkerPath,
14
+ lastStdinByteLength,
14
15
  lazyImport,
15
16
  markerIsTrusted,
16
17
  missingPackageError,
@@ -171,19 +172,34 @@ export async function runJudgeCli(
171
172
  // an invented number is worse than none.
172
173
  /** @type {(() => number) | null} */
173
174
  let elapsed = null;
175
+ /** @type {number | null} */
176
+ let payloadBytes = null;
177
+ /** @type {string | null} */
178
+ let tool = null;
174
179
  try {
175
180
  input = await readInput();
181
+ // Read right after the call that would have set it — an injected test
182
+ // seam bypasses readStdinJson entirely, so lastStdinByteLength() would
183
+ // otherwise report a STALE size left by a previous real call.
184
+ payloadBytes = readInput === readStdinJson ? lastStdinByteLength() : null;
176
185
  // Timed from HERE, not from process start: the wait for the harness to hand
177
186
  // over stdin is not this hook's cost, and blaming it for one would send
178
187
  // operators chasing a bug report that is not theirs to fix.
179
188
  elapsed = startHookTimer();
180
189
  const { claudeAdapter: adapter } = controlPlane();
181
190
  const event = adapter.parse(transformInput(input));
191
+ tool = event.tool ?? null;
182
192
  // Awaited into its own binding first: as an inline argument, `elapsed()`
183
193
  // would be evaluated BEFORE the judge it is supposed to be timing.
184
194
  const judged = await judge(event);
185
195
  const out = nativeStdout(
186
- adapter.render(withSlowHookNotice(hookName, elapsed(), judged), event),
196
+ adapter.render(
197
+ withSlowHookNotice(hookName, elapsed(), judged, undefined, {
198
+ payloadBytes,
199
+ tool,
200
+ }),
201
+ event,
202
+ ),
187
203
  );
188
204
  if (out !== null) write(out);
189
205
  } catch (err) {
@@ -192,7 +208,11 @@ export async function runJudgeCli(
192
208
  // must act on first, and the timing is context for it. stderr only — the
193
209
  // model-facing channel here belongs to onError's fail-closed message, and a
194
210
  // performance aside must not dilute a "this output was never vetted".
195
- if (elapsed !== null) writeSlowHookNotice(hookName, elapsed());
211
+ if (elapsed !== null)
212
+ writeSlowHookNotice(hookName, elapsed(), undefined, {
213
+ payloadBytes,
214
+ tool,
215
+ });
196
216
  onError(err, input);
197
217
  }
198
218
  }
@@ -365,12 +365,35 @@ async function readAllBounded(stream, maxBytes = MAX_STDIN_BYTES) {
365
365
  return Buffer.concat(chunks);
366
366
  }
367
367
 
368
+ /**
369
+ * Byte length of the most recent {@link readStdinJson} read, or null before
370
+ * the first one. Recorded as a side channel rather than widened into
371
+ * `readStdinJson`'s return value, since that return shape is depended on by
372
+ * callers (plugin-hooks.mjs, sanitize-user-prompt.mjs,
373
+ * scan-loaded-instructions.mjs, control-plane.mjs) that only want the parsed
374
+ * payload.
375
+ * @type {number | null}
376
+ */
377
+ let lastStdinBytes = null;
378
+
379
+ /**
380
+ * The byte length recorded by the most recent {@link readStdinJson} call, or
381
+ * null if none has run yet (e.g. a test injected its own `readInput`). Read by
382
+ * `runJudgeCli` to fold the payload size into the slow-hook notice.
383
+ * @returns {number | null}
384
+ */
385
+ export function lastStdinByteLength() {
386
+ return lastStdinBytes;
387
+ }
388
+
368
389
  /**
369
390
  * @param {number} [maxBytes] cap before aborting (overridable for tests)
370
391
  * @returns {Promise<any>}
371
392
  */
372
393
  export async function readStdinJson(maxBytes = MAX_STDIN_BYTES) {
373
- return JSON.parse((await readAllBounded(process.stdin, maxBytes)).toString());
394
+ const buf = await readAllBounded(process.stdin, maxBytes);
395
+ lastStdinBytes = buf.length;
396
+ return JSON.parse(buf.toString());
374
397
  }
375
398
 
376
399
  /**
@@ -69,6 +69,45 @@ export function formatSeconds(ms) {
69
69
  return (Math.round(ms / 100) / 10).toFixed(1);
70
70
  }
71
71
 
72
+ /**
73
+ * Bytes as a human-scaled string (B / KB / MB, one decimal past B) for the
74
+ * slow-hook notice's payload clause. No shell-parity constraint applies here —
75
+ * unlike {@link formatSeconds}, the shell port never has a payload size to
76
+ * print (see plugin/scripts/lib/hook-timing.sh's header).
77
+ * @param {number} bytes
78
+ * @returns {string}
79
+ */
80
+ export function formatBytes(bytes) {
81
+ if (bytes < 1024) return `${bytes} B`;
82
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
83
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
84
+ }
85
+
86
+ /**
87
+ * Debugging context a caller may already have in hand when a hook overruns its
88
+ * budget, so the notice names WHAT was slow instead of just HOW slow — the gap
89
+ * that made this specific latency report take a manual multi-step
90
+ * investigation to characterize (which tool call, how large a payload) before
91
+ * anyone could act on it.
92
+ * @typedef {{ payloadBytes?: number | null, tool?: string | null }} SlowHookContext
93
+ */
94
+
95
+ /**
96
+ * The parenthetical clause naming `context`'s known fields, or `""` when
97
+ * `context` is absent or carries neither — so a caller with no context to give
98
+ * gets the exact same notice text as before this existed.
99
+ * @param {SlowHookContext | undefined} [context]
100
+ * @returns {string}
101
+ */
102
+ function formatContextSuffix(context) {
103
+ if (!context) return "";
104
+ const parts = [];
105
+ if (typeof context.payloadBytes === "number")
106
+ parts.push(`a ${formatBytes(context.payloadBytes)} payload`);
107
+ if (context.tool) parts.push(`tool ${context.tool}`);
108
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
109
+ }
110
+
72
111
  // Process-wide total of milliseconds spent in one-time provisioning. A running
73
112
  // total rather than a flag because a single hook run can pay more than one (a
74
113
  // dependency wait AND a cold daemon spawn), and they may not nest.
@@ -124,17 +163,21 @@ export function startHookTimer(now = Date.now) {
124
163
  * @param {string} hookName
125
164
  * @param {number} elapsedMs
126
165
  * @param {number} [thresholdMs]
166
+ * @param {SlowHookContext} [context] known payload size / triggering tool, so
167
+ * the notice is self-diagnosing rather than requiring the next reader to
168
+ * reconstruct what was slow by hand
127
169
  * @returns {string | null}
128
170
  */
129
171
  export function slowHookNotice(
130
172
  hookName,
131
173
  elapsedMs,
132
174
  thresholdMs = SLOW_HOOK_THRESHOLD_MS,
175
+ context,
133
176
  ) {
134
177
  if (elapsedMs <= thresholdMs) return null;
135
178
  return (
136
179
  `agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
137
- `${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
180
+ `${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget — ` +
138
181
  "this delay is the sanitizer's, not the model's, and every affected call pays it. " +
139
182
  `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and timing.`
140
183
  );
@@ -191,14 +234,16 @@ export function slowProvisionNotice(
191
234
  * @param {string} hookName
192
235
  * @param {number} elapsedMs
193
236
  * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
237
+ * @param {SlowHookContext} [context] see {@link slowHookNotice}
194
238
  * @returns {string | null}
195
239
  */
196
240
  export function writeSlowHookNotice(
197
241
  hookName,
198
242
  elapsedMs,
199
243
  writeErr = (chunk) => process.stderr.write(chunk),
244
+ context,
200
245
  ) {
201
- const notice = slowHookNotice(hookName, elapsedMs);
246
+ const notice = slowHookNotice(hookName, elapsedMs, undefined, context);
202
247
  if (notice === null) return null;
203
248
  writeErr(notice + "\n");
204
249
  return notice;
@@ -217,6 +262,7 @@ export function writeSlowHookNotice(
217
262
  * @param {number} elapsedMs
218
263
  * @param {V} verdict
219
264
  * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
265
+ * @param {SlowHookContext} [context] see {@link slowHookNotice}
220
266
  * @returns {V}
221
267
  */
222
268
  export function withSlowHookNotice(
@@ -224,8 +270,9 @@ export function withSlowHookNotice(
224
270
  elapsedMs,
225
271
  verdict,
226
272
  writeErr = (chunk) => process.stderr.write(chunk),
273
+ context,
227
274
  ) {
228
- const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr);
275
+ const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr, context);
229
276
  if (notice === null) return verdict;
230
277
  return {
231
278
  ...verdict,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.37.4",
3
+ "version": "2.38.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": {
@@ -123,6 +123,13 @@ export function disabledHooks(known: readonly string[], env?: NodeJS.ProcessEnv
123
123
  * @returns {string}
124
124
  */
125
125
  export function failOpenContext(hookName: string, guarded: string, err: unknown, failedPackages?: () => string[], packageMessage?: (pkg: string) => string): string;
126
+ /**
127
+ * The byte length recorded by the most recent {@link readStdinJson} call, or
128
+ * null if none has run yet (e.g. a test injected its own `readInput`). Read by
129
+ * `runJudgeCli` to fold the payload size into the slow-hook notice.
130
+ * @returns {number | null}
131
+ */
132
+ export function lastStdinByteLength(): number | null;
126
133
  /**
127
134
  * @param {number} [maxBytes] cap before aborting (overridable for tests)
128
135
  * @returns {Promise<any>}
@@ -12,6 +12,15 @@
12
12
  * @returns {string}
13
13
  */
14
14
  export function formatSeconds(ms: number): string;
15
+ /**
16
+ * Bytes as a human-scaled string (B / KB / MB, one decimal past B) for the
17
+ * slow-hook notice's payload clause. No shell-parity constraint applies here —
18
+ * unlike {@link formatSeconds}, the shell port never has a payload size to
19
+ * print (see plugin/scripts/lib/hook-timing.sh's header).
20
+ * @param {number} bytes
21
+ * @returns {string}
22
+ */
23
+ export function formatBytes(bytes: number): string;
15
24
  /**
16
25
  * Run `work`, charging its whole duration to provisioning so no timer running
17
26
  * across it counts that time. Charged in a `finally`, so a provisioning step
@@ -48,9 +57,12 @@ export function startHookTimer(now?: () => number): () => number;
48
57
  * @param {string} hookName
49
58
  * @param {number} elapsedMs
50
59
  * @param {number} [thresholdMs]
60
+ * @param {SlowHookContext} [context] known payload size / triggering tool, so
61
+ * the notice is self-diagnosing rather than requiring the next reader to
62
+ * reconstruct what was slow by hand
51
63
  * @returns {string | null}
52
64
  */
53
- export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number): string | null;
65
+ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext): string | null;
54
66
  /**
55
67
  * The line for a ONE-TIME provisioning step that overran
56
68
  * {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
@@ -88,9 +100,10 @@ export function slowProvisionNotice(stepName: string, elapsedMs: number, thresho
88
100
  * @param {string} hookName
89
101
  * @param {number} elapsedMs
90
102
  * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
103
+ * @param {SlowHookContext} [context] see {@link slowHookNotice}
91
104
  * @returns {string | null}
92
105
  */
93
- export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeErr?: (chunk: string) => void): string | null;
106
+ export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeErr?: (chunk: string) => void, context?: SlowHookContext): string | null;
94
107
  /**
95
108
  * `verdict` with the slow-hook notice folded into its `additional_context`, or
96
109
  * the verdict untouched when the run was within budget. Also writes the notice
@@ -104,11 +117,12 @@ export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeEr
104
117
  * @param {number} elapsedMs
105
118
  * @param {V} verdict
106
119
  * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
120
+ * @param {SlowHookContext} [context] see {@link slowHookNotice}
107
121
  * @returns {V}
108
122
  */
109
123
  export function withSlowHookNotice<V extends {
110
124
  additional_context?: string;
111
- }>(hookName: string, elapsedMs: number, verdict: V, writeErr?: (chunk: string) => void): V;
125
+ }>(hookName: string, elapsedMs: number, verdict: V, writeErr?: (chunk: string) => void, context?: SlowHookContext): V;
112
126
  /**
113
127
  * Report a slow run for a hook that answers with a bare `hookSpecificOutput`
114
128
  * envelope rather than a control-plane verdict — SessionStart, which has no
@@ -171,3 +185,14 @@ export const SLOW_HOOK_THRESHOLD_MS: 1000;
171
185
  * re-provisioning every session), which is worth saying out loud.
172
186
  */
173
187
  export const SLOW_PROVISION_THRESHOLD_MS: 60000;
188
+ /**
189
+ * Debugging context a caller may already have in hand when a hook overruns its
190
+ * budget, so the notice names WHAT was slow instead of just HOW slow — the gap
191
+ * that made this specific latency report take a manual multi-step
192
+ * investigation to characterize (which tool call, how large a payload) before
193
+ * anyone could act on it.
194
+ */
195
+ export type SlowHookContext = {
196
+ payloadBytes?: number | null;
197
+ tool?: string | null;
198
+ };