agent-sanitizer 2.51.0 → 2.52.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.
@@ -30,8 +30,13 @@
30
30
  * every session cry wolf, which is the alert fatigue this notice fights.
31
31
  *
32
32
  * Dependency-free on purpose: everything imports this, including hook-io, so a
33
- * back-import would close a cycle. The one emitter it needs is passed in.
33
+ * back-import would close a cycle. The one emitter it needs is passed in. The
34
+ * node builtins below are not such a dependency — they read one small manifest,
35
+ * once, to name this build's version in a report line.
34
36
  */
37
+ import { readFileSync } from "node:fs";
38
+ import { dirname, join } from "node:path";
39
+ import { fileURLToPath } from "node:url";
35
40
 
36
41
  /**
37
42
  * Wall-clock a single hook invocation may spend before it is reported as slow.
@@ -62,6 +67,103 @@ export const SLOW_PROVISION_THRESHOLD_MS = 60000;
62
67
  const ISSUE_URL =
63
68
  "https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
64
69
 
70
+ /**
71
+ * Where this build's own version sits, relative to the directory this module
72
+ * runs from — each shipped artifact puts its manifest at a fixed offset, so the
73
+ * candidates are enumerated rather than searched for:
74
+ *
75
+ * `../../.claude-plugin/plugin.json` the installed Claude Code plugin,
76
+ * whose bundle ships at
77
+ * `plugin/dist/hooks/`
78
+ * `../../plugin/.claude-plugin/plugin.json` a source checkout, where that same
79
+ * manifest is the accurate version
80
+ * and package.json's is the frozen
81
+ * placeholder npm overwrites at
82
+ * publish
83
+ * `../../package.json` the npm package, which ships this
84
+ * module at `claude-hooks/lib/` and
85
+ * carries the published version
86
+ *
87
+ * First hit wins, and each candidate exists only inside the artifact it belongs
88
+ * to, so no foreign manifest is ever a candidate.
89
+ */
90
+ const VERSION_MANIFESTS = [
91
+ "../../.claude-plugin/plugin.json",
92
+ "../../plugin/.claude-plugin/plugin.json",
93
+ "../../package.json",
94
+ ];
95
+
96
+ /** Strict X.Y.Z, the only shape this project's release tooling ever writes. */
97
+ const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+$/;
98
+
99
+ /**
100
+ * This build's version for the report line below, or null when nothing here can
101
+ * name it — a compiled hook binary whose `import.meta.url` points inside the
102
+ * executable reads no manifest, and the notice then asks the operator to look
103
+ * the version up rather than printing one nothing confirmed.
104
+ * @returns {string | null}
105
+ */
106
+ function readVersion() {
107
+ const dir = dirname(fileURLToPath(import.meta.url));
108
+ for (const manifest of VERSION_MANIFESTS) {
109
+ const version = readManifest(join(dir, manifest));
110
+ if (version !== null) return version;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * The strict semver `path` carries, or null when it carries none.
117
+ *
118
+ * The read and the parse are caught because neither failure is this function's
119
+ * business: every candidate but one is absent in any given artifact, and a
120
+ * manifest a packager corrupted is not a reason for a PERFORMANCE notice to
121
+ * throw inside the hook it is reporting on.
122
+ * @param {string} path
123
+ * @returns {string | null}
124
+ */
125
+ function readManifest(path) {
126
+ let manifest;
127
+ try {
128
+ manifest = JSON.parse(readFileSync(path, "utf8"));
129
+ } catch {
130
+ return null;
131
+ }
132
+ return SEMVER.test(manifest?.version) ? manifest.version : null;
133
+ }
134
+
135
+ /** @type {string | null | undefined} */
136
+ let cachedVersion;
137
+
138
+ /**
139
+ * {@link readVersion}, computed once per process — the notice fires on a
140
+ * vanishing fraction of runs, and every hook imports this module on the hot
141
+ * path, so the manifest is read only once something is being reported.
142
+ * @returns {string | null}
143
+ */
144
+ export function sanitizerVersion() {
145
+ if (cachedVersion === undefined) cachedVersion = readVersion();
146
+ return cachedVersion;
147
+ }
148
+
149
+ /**
150
+ * The clause naming the version an issue report should carry: this build's when
151
+ * it knows it, and otherwise an instruction to look it up — never a guess.
152
+ *
153
+ * Resolves the version HERE rather than in a caller's default argument, which
154
+ * would read the manifest on every healthy run too — the notices call this only
155
+ * once they have decided to report.
156
+ * @param {string | null | undefined} version a caller's override; `undefined`
157
+ * asks this build for its own, `null` says nothing could name it
158
+ * @returns {string}
159
+ */
160
+ function versionClause(version) {
161
+ const resolved = version === undefined ? sanitizerVersion() : version;
162
+ return resolved
163
+ ? `agent-sanitizer ${resolved}`
164
+ : "your agent-sanitizer version";
165
+ }
166
+
65
167
  /**
66
168
  * Milliseconds as the seconds string every notice below prints.
67
169
  *
@@ -439,6 +541,9 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
439
541
  * @param {SlowHookContext} [context] known CPU time / payload size /
440
542
  * triggering tool, so the notice is self-diagnosing rather than requiring the
441
543
  * next reader to reconstruct what was slow by hand
544
+ * @param {string | null} [version] the build to name in the report line;
545
+ * omitted asks {@link sanitizerVersion}, and the shell port passes its own,
546
+ * read from the plugin manifest it ships beside
442
547
  * @returns {string | null}
443
548
  */
444
549
  export function slowHookNotice(
@@ -446,6 +551,7 @@ export function slowHookNotice(
446
551
  elapsedMs,
447
552
  thresholdMs = SLOW_HOOK_THRESHOLD_MS,
448
553
  context,
554
+ version,
449
555
  ) {
450
556
  if (elapsedMs <= thresholdMs) return null;
451
557
  const cpuMs = context?.cpuMs;
@@ -473,7 +579,7 @@ export function slowHookNotice(
473
579
  return (
474
580
  `agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
475
581
  `${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
476
- `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ${timings}.`
582
+ `Tell the user, and suggest they report it at ${ISSUE_URL} with ${versionClause(version)}, the hook name and ${timings}.`
477
583
  );
478
584
  }
479
585
 
@@ -498,6 +604,7 @@ export function slowHookNotice(
498
604
  * @param {string} [advice] step-specific speedup advice — the default fits the
499
605
  * engine install; the hook-binary download passes its own, because telling a
500
606
  * user mid-download that uv would help is advice about the wrong step
607
+ * @param {string | null} [version] see {@link slowHookNotice}
501
608
  * @returns {string | null}
502
609
  */
503
610
  export function slowProvisionNotice(
@@ -505,13 +612,14 @@ export function slowProvisionNotice(
505
612
  elapsedMs,
506
613
  thresholdMs = SLOW_PROVISION_THRESHOLD_MS,
507
614
  advice = "Installing uv makes it faster",
615
+ version,
508
616
  ) {
509
617
  if (elapsedMs <= thresholdMs) return null;
510
618
  return (
511
619
  `agent-sanitizer PERFORMANCE: one-time setup (${stepName}) took ` +
512
620
  `${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
513
621
  "this is paid once per install, not per tool call, so the session is not slow from here on. " +
514
- `${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
622
+ `${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL} with ${versionClause(version)}.`
515
623
  );
516
624
  }
517
625
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.51.0",
3
+ "version": "2.52.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": {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * {@link readVersion}, computed once per process — the notice fires on a
3
+ * vanishing fraction of runs, and every hook imports this module on the hot
4
+ * path, so the manifest is read only once something is being reported.
5
+ * @returns {string | null}
6
+ */
7
+ export function sanitizerVersion(): string | null;
1
8
  /**
2
9
  * Milliseconds as the seconds string every notice below prints.
3
10
  *
@@ -149,9 +156,12 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
149
156
  * @param {SlowHookContext} [context] known CPU time / payload size /
150
157
  * triggering tool, so the notice is self-diagnosing rather than requiring the
151
158
  * next reader to reconstruct what was slow by hand
159
+ * @param {string | null} [version] the build to name in the report line;
160
+ * omitted asks {@link sanitizerVersion}, and the shell port passes its own,
161
+ * read from the plugin manifest it ships beside
152
162
  * @returns {string | null}
153
163
  */
154
- export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext): string | null;
164
+ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext, version?: string | null): string | null;
155
165
  /**
156
166
  * The line for a ONE-TIME provisioning step that overran
157
167
  * {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
@@ -173,9 +183,10 @@ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?
173
183
  * @param {string} [advice] step-specific speedup advice — the default fits the
174
184
  * engine install; the hook-binary download passes its own, because telling a
175
185
  * user mid-download that uv would help is advice about the wrong step
186
+ * @param {string | null} [version] see {@link slowHookNotice}
176
187
  * @returns {string | null}
177
188
  */
178
- export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string): string | null;
189
+ export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string, version?: string | null): string | null;
179
190
  /**
180
191
  * Write the slow-hook notice to stderr and return it, or return null when the
181
192
  * run was within budget (writing nothing, so the quiet path stays quiet).
@@ -228,40 +239,6 @@ export function withSlowHookNotice<V extends {
228
239
  * @returns {boolean} whether a notice was emitted
229
240
  */
230
241
  export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void, context?: SlowHookContext): boolean;
231
- /**
232
- * The one place a hook's own cost is measured and reported — one threshold, one
233
- * message, one merge rule, shared by every hook.
234
- *
235
- * These hooks sit on the critical path of every tool call, prompt and session
236
- * start: whatever they spend, the user waits. A slow hook is also the hardest
237
- * bug to notice from inside — it looks exactly like a slow agent, so it goes
238
- * unreported for weeks (one SessionStart scan blocked startup for 30 SECONDS
239
- * before anyone traced it back here). A hook past the budget therefore says so
240
- * IN BAND, in the model's context, where it can be relayed to the operator.
241
- *
242
- * FOUR numbers, because wall-clock alone cannot say whose cost it is: a hook on
243
- * a contended host waits far longer than it computes (a 1.1 KB payload and a
244
- * 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
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.
257
- *
258
- * ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
259
- * an install to the hook that merely waited it out would make the FIRST call of
260
- * every session cry wolf, which is the alert fatigue this notice fights.
261
- *
262
- * Dependency-free on purpose: everything imports this, including hook-io, so a
263
- * back-import would close a cycle. The one emitter it needs is passed in.
264
- */
265
242
  /**
266
243
  * Wall-clock a single hook invocation may spend before it is reported as slow.
267
244
  *