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(
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
+
};
|