agent-sanitizer 2.59.0 → 2.60.1
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.
|
@@ -152,6 +152,9 @@ export function nativeStdout(response) {
|
|
|
152
152
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
153
153
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
154
154
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
155
|
+
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
156
|
+
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
157
|
+
* omitted, the notice names this package
|
|
155
158
|
* @returns {Promise<void>}
|
|
156
159
|
*/
|
|
157
160
|
export async function runJudgeCli(
|
|
@@ -162,6 +165,7 @@ export async function runJudgeCli(
|
|
|
162
165
|
transformInput = (raw) => raw,
|
|
163
166
|
readInput = readStdinJson,
|
|
164
167
|
write = (chunk) => process.stdout.write(chunk),
|
|
168
|
+
reportTo,
|
|
165
169
|
},
|
|
166
170
|
) {
|
|
167
171
|
let input;
|
|
@@ -200,6 +204,7 @@ export async function runJudgeCli(
|
|
|
200
204
|
cpuMs: timer.cpuMs(),
|
|
201
205
|
redactorMs: timer.redactorMs(),
|
|
202
206
|
hostMs: timer.hostMs(),
|
|
207
|
+
reportTo,
|
|
203
208
|
}),
|
|
204
209
|
event,
|
|
205
210
|
),
|
|
@@ -218,6 +223,7 @@ export async function runJudgeCli(
|
|
|
218
223
|
cpuMs: timer.cpuMs(),
|
|
219
224
|
redactorMs: timer.redactorMs(),
|
|
220
225
|
hostMs: timer.hostMs(),
|
|
226
|
+
reportTo,
|
|
221
227
|
});
|
|
222
228
|
onError(err, input);
|
|
223
229
|
}
|
|
@@ -102,8 +102,10 @@ const ISSUE_URL =
|
|
|
102
102
|
* module at `claude-hooks/lib/` and
|
|
103
103
|
* carries the published version
|
|
104
104
|
*
|
|
105
|
-
* First hit wins, and
|
|
106
|
-
*
|
|
105
|
+
* First hit wins, and only a manifest naming {@link OWN_NAME} counts. The offsets
|
|
106
|
+
* alone do not exclude foreign manifests: a composer that bundles this module
|
|
107
|
+
* into its own `.claude/hooks/` puts `../../package.json` on the COMPOSER's
|
|
108
|
+
* manifest, whose version would otherwise be reported as this build's.
|
|
107
109
|
*/
|
|
108
110
|
const VERSION_MANIFESTS = [
|
|
109
111
|
"../../.claude-plugin/plugin.json",
|
|
@@ -111,6 +113,9 @@ const VERSION_MANIFESTS = [
|
|
|
111
113
|
"../../package.json",
|
|
112
114
|
];
|
|
113
115
|
|
|
116
|
+
/** The `name` both this package's package.json and its plugin.json carry. */
|
|
117
|
+
const OWN_NAME = "agent-sanitizer";
|
|
118
|
+
|
|
114
119
|
/** Strict X.Y.Z, the only shape this project's release tooling ever writes. */
|
|
115
120
|
const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
116
121
|
|
|
@@ -131,7 +136,8 @@ function readVersion() {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
|
-
* The strict semver `path` carries, or null when it carries none
|
|
139
|
+
* The strict semver `path` carries, or null when it carries none or names a
|
|
140
|
+
* package other than {@link OWN_NAME}.
|
|
135
141
|
*
|
|
136
142
|
* The read and the parse are caught because neither failure is this function's
|
|
137
143
|
* business: every candidate but one is absent in any given artifact, and a
|
|
@@ -147,7 +153,9 @@ function readManifest(path) {
|
|
|
147
153
|
} catch {
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
|
-
return SEMVER.test(manifest
|
|
156
|
+
return manifest?.name === OWN_NAME && SEMVER.test(manifest.version)
|
|
157
|
+
? manifest.version
|
|
158
|
+
: null;
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/** @type {string | null | undefined} */
|
|
@@ -182,6 +190,45 @@ function versionClause(version) {
|
|
|
182
190
|
: "your agent-sanitizer version";
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A composer that bundles this hook runtime into hooks of its own names itself
|
|
195
|
+
* here, so a slow notice from one of ITS hooks charges the cost to it and sends
|
|
196
|
+
* the report to its tracker rather than this package's.
|
|
197
|
+
* @typedef {{
|
|
198
|
+
* name: string,
|
|
199
|
+
* issueUrl: string,
|
|
200
|
+
* version?: string | null,
|
|
201
|
+
* }} ReportTo
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Who a slow-hook notice names as the hook's owner, where it sends the report,
|
|
206
|
+
* and the version clause the report carries: this package unless `reportTo`
|
|
207
|
+
* names a composer. A composer that passes no version gets the look-it-up
|
|
208
|
+
* clause, never this package's version under its name.
|
|
209
|
+
* @param {ReportTo | undefined} reportTo
|
|
210
|
+
* @param {string | null | undefined} version see {@link versionClause}
|
|
211
|
+
*/
|
|
212
|
+
function noticeOwner(reportTo, version) {
|
|
213
|
+
if (reportTo === undefined)
|
|
214
|
+
return {
|
|
215
|
+
label: "agent-sanitizer",
|
|
216
|
+
owner: "the sanitizer",
|
|
217
|
+
issueUrl: ISSUE_URL,
|
|
218
|
+
versionClause: versionClause(version),
|
|
219
|
+
composer: undefined,
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
label: reportTo.name,
|
|
223
|
+
owner: reportTo.name,
|
|
224
|
+
issueUrl: reportTo.issueUrl,
|
|
225
|
+
versionClause: reportTo.version
|
|
226
|
+
? `${reportTo.name} ${reportTo.version}`
|
|
227
|
+
: `your ${reportTo.name} version`,
|
|
228
|
+
composer: reportTo.name,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
/**
|
|
186
233
|
* Milliseconds as the seconds string every notice below prints.
|
|
187
234
|
*
|
|
@@ -229,6 +276,7 @@ export function formatBytes(bytes) {
|
|
|
229
276
|
* cpuMs?: number | null,
|
|
230
277
|
* redactorMs?: number | null,
|
|
231
278
|
* hostMs?: number | null,
|
|
279
|
+
* reportTo?: ReportTo,
|
|
232
280
|
* }} SlowHookContext
|
|
233
281
|
*/
|
|
234
282
|
|
|
@@ -555,9 +603,10 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
|
555
603
|
* @param {number} redactorMs
|
|
556
604
|
* @param {number | undefined} hostMs absent when the caller measures no host
|
|
557
605
|
* callbacks; a caller that does pass 0 when none ran
|
|
606
|
+
* @param {ReturnType<typeof noticeOwner>} owner
|
|
558
607
|
* @returns {string}
|
|
559
608
|
*/
|
|
560
|
-
function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
609
|
+
function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner) {
|
|
561
610
|
// `hostMs` absent means the caller cannot measure that window, not that it was
|
|
562
611
|
// empty — a caller that CAN measure passes 0 and gets the zero printed. So an
|
|
563
612
|
// absent one is left out of both the sentence and the remainder, and the
|
|
@@ -569,12 +618,14 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
|
569
618
|
redactorMs === largest
|
|
570
619
|
? "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."
|
|
571
620
|
: hostMs !== undefined && hostMs === largest
|
|
572
|
-
?
|
|
621
|
+
? owner.composer === undefined
|
|
622
|
+
? "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."
|
|
623
|
+
: `The largest share was spent inside a host extension this hook called — a callback ${owner.composer} injected, and a cost ${owner.composer} owns; neither this hook nor the redactor was computing it.`
|
|
573
624
|
: cpuMs === largest
|
|
574
|
-
?
|
|
625
|
+
? `The largest share is this hook computing — a per-call cost ${owner.owner} owns, repeated by every affected call.`
|
|
575
626
|
: hostMs === undefined
|
|
576
|
-
?
|
|
577
|
-
:
|
|
627
|
+
? `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 ${owner.owner} that it called.`
|
|
628
|
+
: `The largest share is none of those three: it was blocked on a loaded machine or on something outside ${owner.owner} that it called without measuring.`;
|
|
578
629
|
const hostClause =
|
|
579
630
|
hostMs === undefined
|
|
580
631
|
? ""
|
|
@@ -609,7 +660,8 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
|
609
660
|
* @param {number} [thresholdMs]
|
|
610
661
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
611
662
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
612
|
-
* next reader to reconstruct what was slow by hand
|
|
663
|
+
* next reader to reconstruct what was slow by hand; its `reportTo` names the
|
|
664
|
+
* composer that owns the hook, and replaces the owner, tracker and version
|
|
613
665
|
* @param {string | null} [version] the build to name in the report line;
|
|
614
666
|
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
615
667
|
* read from the plugin manifest it ships beside
|
|
@@ -632,12 +684,13 @@ export function slowHookNotice(
|
|
|
632
684
|
// number, including when it is 0.
|
|
633
685
|
const hostMs =
|
|
634
686
|
typeof context?.hostMs === "number" ? context.hostMs : undefined;
|
|
687
|
+
const owner = noticeOwner(context?.reportTo, version);
|
|
635
688
|
const attribution = attributed
|
|
636
|
-
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs)
|
|
689
|
+
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner)
|
|
637
690
|
: typeof cpuMs === "number"
|
|
638
691
|
? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
|
|
639
692
|
"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."
|
|
640
|
-
:
|
|
693
|
+
: `. Wall-clock alone cannot separate ${owner.owner}'s own work from a busy machine.`;
|
|
641
694
|
const timings = attributed
|
|
642
695
|
? hostMs === undefined
|
|
643
696
|
? "all three timings"
|
|
@@ -646,9 +699,9 @@ export function slowHookNotice(
|
|
|
646
699
|
? "both timings"
|
|
647
700
|
: "timing";
|
|
648
701
|
return (
|
|
649
|
-
|
|
702
|
+
`${owner.label} PERFORMANCE: the ${hookName} hook took ` +
|
|
650
703
|
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
|
|
651
|
-
`Tell the user, and suggest they report it at ${
|
|
704
|
+
`Tell the user, and suggest they report it at ${owner.issueUrl} with ${owner.versionClause}, the hook name and ${timings}.`
|
|
652
705
|
);
|
|
653
706
|
}
|
|
654
707
|
|
|
@@ -27,15 +27,23 @@ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" w
|
|
|
27
27
|
// every gap are what bound the match — a `*` gap is never adjacent to another
|
|
28
28
|
// gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
|
|
29
29
|
// is irrelevant to that: a character class matches in O(1) whether it holds one
|
|
30
|
-
// member or all 435.)
|
|
30
|
+
// member or all 435.) That argument holds only while no literal is itself a
|
|
31
|
+
// member of the class, so {@link envValueRegex} drops the value's own invisible
|
|
32
|
+
// code points before it splices: `a<ZWSP>a` would otherwise build
|
|
33
|
+
// `a[…]*<ZWSP>[…]*a`, whose two stars share the literal and backtrack
|
|
34
|
+
// quadratically over a long run of ZWSP.
|
|
35
|
+
const ENV_INVIS_CODEPOINTS = [
|
|
36
|
+
...new Set([...charset.cf_codepoints, ...charset.extra_codepoints]),
|
|
37
|
+
].sort((a, b) => a - b);
|
|
38
|
+
const ENV_INVIS_SET = new Set(ENV_INVIS_CODEPOINTS);
|
|
31
39
|
const ENV_INVIS_RUN =
|
|
32
40
|
"[" +
|
|
33
|
-
|
|
34
|
-
.sort((a, b) => a - b)
|
|
35
|
-
.map((cp) => `\\u{${cp.toString(16)}}`)
|
|
36
|
-
.join("") +
|
|
41
|
+
ENV_INVIS_CODEPOINTS.map((cp) => `\\u{${cp.toString(16)}}`).join("") +
|
|
37
42
|
"]*";
|
|
38
43
|
|
|
44
|
+
/** @param {string} ch */
|
|
45
|
+
const escapeLiteral = (ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* Regex matching `value` tolerating invisible chars spliced between its
|
|
41
49
|
* characters (mirrors the engine's env-value regex). Code-point split so
|
|
@@ -55,10 +63,15 @@ const ENV_INVIS_RUN =
|
|
|
55
63
|
export function envValueRegex(value) {
|
|
56
64
|
let re = ENV_VALUE_REGEX_CACHE.get(value);
|
|
57
65
|
if (re === undefined) {
|
|
66
|
+
const visible = [...value].filter(
|
|
67
|
+
(ch) => !ENV_INVIS_SET.has(/** @type {number} */ (ch.codePointAt(0))),
|
|
68
|
+
);
|
|
69
|
+
// A value made only of invisibles has no literal to anchor a gap on: match
|
|
70
|
+
// it exactly rather than build a pattern that is nothing but gaps.
|
|
58
71
|
re = new RegExp(
|
|
59
|
-
|
|
60
|
-
.map(
|
|
61
|
-
.join(
|
|
72
|
+
visible.length > 0
|
|
73
|
+
? visible.map(escapeLiteral).join(ENV_INVIS_RUN)
|
|
74
|
+
: [...value].map(escapeLiteral).join(""),
|
|
62
75
|
"u",
|
|
63
76
|
);
|
|
64
77
|
ENV_VALUE_REGEX_CACHE.set(value, re);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.60.1",
|
|
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": {
|
|
@@ -57,11 +57,15 @@ export function nativeStdout(response: {
|
|
|
57
57
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
58
58
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
59
59
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
60
|
+
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
61
|
+
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
62
|
+
* omitted, the notice names this package
|
|
60
63
|
* @returns {Promise<void>}
|
|
61
64
|
*/
|
|
62
|
-
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, }: {
|
|
65
|
+
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, reportTo, }: {
|
|
63
66
|
onError: (err: unknown, input: unknown) => void;
|
|
64
67
|
transformInput?: ((input: unknown) => unknown) | undefined;
|
|
65
68
|
readInput?: (() => Promise<unknown>) | undefined;
|
|
66
69
|
write?: ((chunk: string) => void) | undefined;
|
|
70
|
+
reportTo?: import("./hook-timing.mjs").ReportTo | undefined;
|
|
67
71
|
}): Promise<void>;
|
|
@@ -184,7 +184,8 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
184
184
|
* @param {number} [thresholdMs]
|
|
185
185
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
186
186
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
187
|
-
* next reader to reconstruct what was slow by hand
|
|
187
|
+
* next reader to reconstruct what was slow by hand; its `reportTo` names the
|
|
188
|
+
* composer that owns the hook, and replaces the owner, tracker and version
|
|
188
189
|
* @param {string | null} [version] the build to name in the report line;
|
|
189
190
|
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
190
191
|
* read from the plugin manifest it ships beside
|
|
@@ -303,6 +304,16 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
|
303
304
|
* alongside it must not be what buys that silence.
|
|
304
305
|
*/
|
|
305
306
|
export const CONCURRENT_PROVISION_CEILING_MS: number;
|
|
307
|
+
/**
|
|
308
|
+
* A composer that bundles this hook runtime into hooks of its own names itself
|
|
309
|
+
* here, so a slow notice from one of ITS hooks charges the cost to it and sends
|
|
310
|
+
* the report to its tracker rather than this package's.
|
|
311
|
+
*/
|
|
312
|
+
export type ReportTo = {
|
|
313
|
+
name: string;
|
|
314
|
+
issueUrl: string;
|
|
315
|
+
version?: string | null;
|
|
316
|
+
};
|
|
306
317
|
/**
|
|
307
318
|
* Debugging context a caller may already have in hand when a hook overruns its
|
|
308
319
|
* budget, so the notice names WHAT was slow instead of just HOW slow — the gap
|
|
@@ -320,6 +331,7 @@ export type SlowHookContext = {
|
|
|
320
331
|
cpuMs?: number | null;
|
|
321
332
|
redactorMs?: number | null;
|
|
322
333
|
hostMs?: number | null;
|
|
334
|
+
reportTo?: ReportTo;
|
|
323
335
|
};
|
|
324
336
|
/**
|
|
325
337
|
* This process's own user+system processor time so far, in milliseconds.
|