agent-sanitizer 2.8.0 → 2.9.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.
@@ -7,7 +7,16 @@
7
7
  * transport rule (nativeStdout), and the shared judge-CLI transport
8
8
  * (runJudgeCli).
9
9
  */
10
- import { errMessage, lazyImport, readStdinJson } from "./hook-io.mjs";
10
+ import {
11
+ awaitLazyDependency,
12
+ errMessage,
13
+ hookgateMarkerPath,
14
+ lazyImport,
15
+ markerIsTrusted,
16
+ missingPackageError,
17
+ probeSetupAlive,
18
+ readStdinJson,
19
+ } from "./hook-io.mjs";
11
20
 
12
21
  // Loaded via a *caught* dynamic import — never a bare static `import … from`.
13
22
  // A static npm import resolves before any try/catch, so a missing node_modules
@@ -26,20 +35,36 @@ let EventKind;
26
35
  /* c8 ignore start -- module-load boundary: the real import resolves in every
27
36
  in-process test and spawned CLI run, and a missing node_modules can't be
28
37
  simulated in-process, so this glue's failure arm is unobservable here. The
29
- observable behaviour is controlPlane()'s throw, unit-tested directly. */
38
+ observable logic lives in awaitLazyDependency, unit-tested directly. */
30
39
  // Stryker disable all
31
- {
32
- const { claudeAdapter: adapter } =
33
- /** @type {Partial<typeof import("agent-control-plane-core/claude")>} */ (
34
- await lazyImport("agent-control-plane-core/claude")
35
- );
36
- const { Decision: decision, EventKind: eventKind } =
37
- /** @type {Partial<typeof import("agent-control-plane-core")>} */ (
38
- await lazyImport("agent-control-plane-core")
40
+ const marker = hookgateMarkerPath();
41
+ const loaded = await awaitLazyDependency({
42
+ tryImport: async () => {
43
+ // lazyImport is the shared caught npm import (see hook-io): it returns the
44
+ // module on success and {} on a failed load, so a missing binding is the
45
+ // null signal the poll loop waits on — no bare import()/try-catch here.
46
+ const { claudeAdapter: adapter } =
47
+ /** @type {Partial<typeof import("agent-control-plane-core/claude")>} */ (
48
+ await lazyImport("agent-control-plane-core/claude")
49
+ );
50
+ const { Decision: decision, EventKind: eventKind } =
51
+ /** @type {Partial<typeof import("agent-control-plane-core")>} */ (
52
+ await lazyImport("agent-control-plane-core")
53
+ );
54
+ if (!adapter || !decision || !eventKind) return null;
55
+ return { claudeAdapter: adapter, Decision: decision, EventKind: eventKind };
56
+ },
57
+ markerPresent: () => markerIsTrusted(marker),
58
+ setupAlive: () => probeSetupAlive(marker),
59
+ });
60
+ if (loaded) {
61
+ const bound =
62
+ /** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind }} */ (
63
+ loaded
39
64
  );
40
- claudeAdapter = adapter;
41
- Decision = decision;
42
- EventKind = eventKind;
65
+ claudeAdapter = bound.claudeAdapter;
66
+ Decision = bound.Decision;
67
+ EventKind = bound.EventKind;
43
68
  }
44
69
  // Stryker restore all
45
70
  /* c8 ignore stop */
@@ -58,7 +83,7 @@ let EventKind;
58
83
  export function controlPlane(overrides = {}) {
59
84
  const bindings = { claudeAdapter, Decision, EventKind, ...overrides };
60
85
  if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
61
- throw new Error("agent-control-plane-core is unavailable");
86
+ throw missingPackageError("agent-control-plane-core");
62
87
  return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
63
88
  }
64
89
 
@@ -4,10 +4,12 @@ import {
4
4
  openSync,
5
5
  closeSync,
6
6
  lstatSync,
7
+ readFileSync,
7
8
  unlinkSync,
8
9
  writeFileSync,
9
10
  } from "node:fs";
10
11
  import { userInfo } from "node:os";
12
+ import { createHash } from "node:crypto";
11
13
  import { pathToFileURL } from "node:url";
12
14
 
13
15
  let cliEntryClaimed = false;
@@ -154,6 +156,14 @@ export function registeredLazyModule(specifier) {
154
156
  return registeredLazyModules[specifier];
155
157
  }
156
158
 
159
+ /**
160
+ * Last load error per specifier, recorded when {@link lazyImport} swallows a
161
+ * failed import. Read by {@link lazyImportErrorFor} so a fail-closed hook can
162
+ * say WHY its dependency is absent instead of a bare "unavailable".
163
+ * @type {Map<string, unknown>}
164
+ */
165
+ const lazyImportErrors = new Map();
166
+
157
167
  /**
158
168
  * Dynamic-import `specifier`, yielding `{}` when the module cannot be loaded.
159
169
  * Hooks bind their npm packages through this instead of a bare static import: a
@@ -169,14 +179,121 @@ export function registeredLazyModule(specifier) {
169
179
  */
170
180
  export async function lazyImport(specifier) {
171
181
  const registered = registeredLazyModules[specifier];
172
- if (registered) return registered;
182
+ if (registered) {
183
+ lazyImportErrors.delete(specifier);
184
+ return registered;
185
+ }
173
186
  try {
174
- return await import(specifier);
175
- } catch {
187
+ const loaded = await import(specifier);
188
+ lazyImportErrors.delete(specifier);
189
+ return loaded;
190
+ } catch (err) {
191
+ // Delete before set: a Map keeps a re-set key at its ORIGINAL insertion
192
+ // position, and both readers below are recency-ordered — so re-recording in
193
+ // place would let a stale first failure outrank the one that just happened.
194
+ lazyImportErrors.delete(specifier);
195
+ lazyImportErrors.set(specifier, err);
176
196
  return {};
177
197
  }
178
198
  }
179
199
 
200
+ /**
201
+ * The most recently recorded load error for `pkg` under any of its specifiers —
202
+ * the bare package or a subpath export (`pkg/output`, `pkg/invisible`) — or
203
+ * undefined when none is recorded. Hooks import a package through several
204
+ * subpaths; any one of them names why the package is absent, and the newest
205
+ * record reflects the current failure when they differ.
206
+ * @param {string} pkg
207
+ * @returns {unknown}
208
+ */
209
+ export function lazyImportErrorFor(pkg) {
210
+ for (const [specifier, err] of [...lazyImportErrors].reverse())
211
+ if (specifier === pkg || specifier.startsWith(`${pkg}/`)) return err;
212
+ return undefined;
213
+ }
214
+
215
+ /**
216
+ * Package names (never relative-path specifiers) with a recorded load error,
217
+ * newest first — so a fail-closed reason can name whichever dependency actually
218
+ * failed instead of consulting a hardcoded package list.
219
+ * @returns {string[]}
220
+ */
221
+ export function failedLazyPackages() {
222
+ const pkgs = new Set();
223
+ for (const specifier of [...lazyImportErrors.keys()].reverse()) {
224
+ // Only bare package names: relative/absolute paths and URL specifiers
225
+ // (file:, node:) are not npm packages a reinstall could restore.
226
+ if (!/^[\w@]/.test(specifier) || specifier.includes(":")) continue;
227
+ const parts = specifier.split("/");
228
+ pkgs.add(
229
+ specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0],
230
+ );
231
+ }
232
+ return [...pkgs];
233
+ }
234
+
235
+ /**
236
+ * The remedy {@link missingPackageMessage} states when the host does not supply
237
+ * one of its own. A host whose install has a specific entry point (a setup
238
+ * script, a devcontainer rebuild) passes that instead, so the reason names the
239
+ * command the reader should actually run.
240
+ */
241
+ export const DEFAULT_MISSING_PACKAGE_REMEDY =
242
+ "reinstall the hook dependencies (pnpm install) and retry.";
243
+
244
+ /**
245
+ * The fail-closed reason for a package a hook could not load: the recorded
246
+ * loader error plus the remedy. The cause is scrubbed (it is spliced into
247
+ * reasons shown to user and model) and its cap is COMPUTED so that
248
+ * prefix + cause + remedy always fits the downstream 300-char safeErrMessage
249
+ * re-scrub — the remedy can never be truncated off, whatever the package name.
250
+ * A remedy that alone exceeds that budget (roughly 260 characters) leaves the
251
+ * cause nothing to spend and still overruns; keep host remedies to a sentence.
252
+ * @param {string} pkg
253
+ * @param {unknown} [err]
254
+ * @param {string} [remedy]
255
+ * @returns {string}
256
+ */
257
+ export function missingPackageMessage(
258
+ pkg,
259
+ err = lazyImportErrorFor(pkg),
260
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
261
+ ) {
262
+ const prefix = `${pkg} is unavailable: `;
263
+ // 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
264
+ // which lands past its cap when the cause is cut. Clamped at 0 because the
265
+ // remedy is host text: a long one drives the budget negative, and
266
+ // safeErrMessage does not clamp — `slice(0, -n)` trims from the END, returning
267
+ // nearly the whole cause and pushing the joined message past 300, so the
268
+ // downstream re-scrub cuts the remedy off. At 0 the cause degrades to the
269
+ // truncation marker and the remedy always survives.
270
+ const causeCap = Math.max(0, 300 - prefix.length - remedy.length - 2 - 12);
271
+ const cause =
272
+ err === undefined
273
+ ? "no load error recorded — the package likely loaded but lacks an expected export (version skew)"
274
+ : safeErrMessage(err, causeCap);
275
+ return `${prefix}${cause}; ${remedy}`;
276
+ }
277
+
278
+ /**
279
+ * {@link missingPackageMessage} as a throwable, tagged `code: "DEP_UNAVAILABLE"`
280
+ * so downstream reason-builders can recognize it structurally and not append a
281
+ * second copy of the same cause.
282
+ * @param {string} pkg
283
+ * @param {unknown} [err]
284
+ * @param {string} [remedy]
285
+ * @returns {Error}
286
+ */
287
+ export function missingPackageError(
288
+ pkg,
289
+ err = lazyImportErrorFor(pkg),
290
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
291
+ ) {
292
+ return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
293
+ code: "DEP_UNAVAILABLE",
294
+ });
295
+ }
296
+
180
297
  /**
181
298
  * A monotonic wall-clock budget shared across one hook run's downstream blocking
182
299
  * calls. `remainingMs()` returns the milliseconds left until the budget is spent
@@ -274,6 +391,148 @@ export function emitHookResponse(hookEventName, fields) {
274
391
  );
275
392
  }
276
393
 
394
+ /** The marker filename stem; the project directory is appended to it. */
395
+ const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
396
+
397
+ /**
398
+ * Path of the cold-start in-flight marker a host's setup script writes
399
+ * SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
400
+ * and removes once the hook dependencies are provisioned. A hook that fires
401
+ * before setup finishes finds the marker and WAITS for its dependency rather
402
+ * than failing closed on it — so the first turn is merely delayed, never
403
+ * blocked, for as long as setup is still alive (the PID lets the hook tell a
404
+ * live install from a stale marker left by a killed setup). Derived purely from
405
+ * the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
406
+ * canonicalization — the two must produce byte-identical paths), so no env has
407
+ * to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
408
+ * setup ran → nothing to wait on).
409
+ * @param {string | undefined} [projectDir]
410
+ * @param {string | undefined} [runtimeDir]
411
+ * @returns {string | null}
412
+ */
413
+ export function hookgateMarkerPath(
414
+ projectDir = process.env.CLAUDE_PROJECT_DIR,
415
+ runtimeDir = process.env.XDG_RUNTIME_DIR,
416
+ ) {
417
+ if (!projectDir) return null;
418
+ // Prefer the per-user, mode-0700 runtime dir when the harness gives an
419
+ // absolute one; else the world-writable /tmp, where markerIsTrusted() — not
420
+ // the path — defends against a squatted marker.
421
+ const base = runtimeDir && runtimeDir.startsWith("/") ? runtimeDir : "/tmp";
422
+ // The flattened dir is for a human reading `ls /tmp`; the digest of the RAW
423
+ // dir is the identity. Flattening alone is lossy — /work/a-b, /work/a_b and
424
+ // "/work/a b" all collapse to one name — and two such projects on one machine
425
+ // would then share a marker: B's hook waits out A's install for a dependency A
426
+ // is not installing, and A clearing the marker aborts B's legitimate wait. Both
427
+ // directions are silent. A setup script reproduces the digest with sha256sum.
428
+ const digest = createHash("sha256")
429
+ .update(projectDir)
430
+ .digest("hex")
431
+ .slice(0, 8);
432
+ const flattened = projectDir.replace(/[^A-Za-z0-9]/g, "_");
433
+ return `${base}/${HOOKGATE_MARKER_STEM}${flattened}-${digest}`;
434
+ }
435
+
436
+ /**
437
+ * Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
438
+ * probes liveness without signalling: it throws ESRCH once the process is gone (a
439
+ * killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
440
+ * ours (still alive). An unreadable / not-yet-written marker is treated as alive —
441
+ * favouring a brief wait over a premature give-up during setup's write race. A null
442
+ * markerPath (no project dir → no setup to wait on) reads as alive so the caller's
443
+ * own grace/ceiling bound governs.
444
+ * @param {string | null} markerPath
445
+ * @returns {boolean}
446
+ */
447
+ export function probeSetupAlive(markerPath) {
448
+ if (markerPath === null) return true;
449
+ let pid;
450
+ try {
451
+ pid = parseInt(readFileSync(markerPath, "utf8"), 10);
452
+ } catch {
453
+ return true;
454
+ }
455
+ if (!Number.isInteger(pid) || pid <= 0) return true;
456
+ try {
457
+ process.kill(pid, 0);
458
+ return true;
459
+ } catch (err) {
460
+ return /** @type {NodeJS.ErrnoException} */ (err).code === "EPERM";
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Resolve a lazily-loaded dependency, blocking through the cold-start window while
466
+ * setup is still installing it. Returns the loaded value, or null once it gives up
467
+ * (the caller leaves its bindings undefined so the hook fails closed). It waits for
468
+ * as long as setup is genuinely alive, so a slow install is never cut off; the only
469
+ * bound on that wait is a backstop ceiling that stays under the hook's harness
470
+ * timeout — a hook killed for running over is a fail-OPEN, the opposite of what a
471
+ * gate wants. The give-up cases are the honest ones (setup finished/died without the
472
+ * dep, or no setup at all), so a genuinely-absent dep fails closed fast, never after
473
+ * a long block:
474
+ * - import succeeds → return immediately (warm session: no wait).
475
+ * - marker present AND setup alive → setup is working; wait it out (ceilingMs is a
476
+ * backstop only, for a hung-but-alive setup).
477
+ * - was installing, now not (marker cleared, or a stale marker from a killed setup)
478
+ * → settleMs grace for a just-orphaned install to
479
+ * land, then give up: the dep is absent.
480
+ * - no live setup ever seen → wait only graceMs (tolerating setup not having
481
+ * written the marker yet), then give up.
482
+ * @param {{
483
+ * tryImport: () => Promise<object | null>,
484
+ * markerPresent: () => boolean,
485
+ * setupAlive: () => boolean,
486
+ * now?: () => number,
487
+ * sleep?: (ms: number) => Promise<void>,
488
+ * graceMs?: number,
489
+ * settleMs?: number,
490
+ * ceilingMs?: number,
491
+ * intervalMs?: number,
492
+ * }} deps
493
+ * @returns {Promise<object | null>}
494
+ */
495
+ export async function awaitLazyDependency({
496
+ tryImport,
497
+ markerPresent,
498
+ setupAlive,
499
+ now = () => Date.now(),
500
+ sleep = (ms) =>
501
+ new Promise((resolve) => {
502
+ setTimeout(resolve, ms);
503
+ }),
504
+ graceMs = 5000,
505
+ settleMs = 1000,
506
+ ceilingMs = 900000,
507
+ intervalMs = 250,
508
+ }) {
509
+ const start = now();
510
+ let sawInstalling = false;
511
+ let enteredDone = false;
512
+ let doneAt = 0;
513
+ for (;;) {
514
+ const bindings = await tryImport();
515
+ if (bindings) return bindings;
516
+ const installing = markerPresent() && setupAlive();
517
+ let giveUp;
518
+ if (installing) {
519
+ sawInstalling = true;
520
+ enteredDone = false;
521
+ giveUp = now() - start > ceilingMs;
522
+ } else if (sawInstalling) {
523
+ if (!enteredDone) {
524
+ enteredDone = true;
525
+ doneAt = now();
526
+ }
527
+ giveUp = now() - doneAt > settleMs;
528
+ } else {
529
+ giveUp = now() - start > graceMs;
530
+ }
531
+ if (giveUp) return null;
532
+ await sleep(intervalMs);
533
+ }
534
+ }
535
+
277
536
  /**
278
537
  * Is the file at `path` one WE wrote — a regular file owned by this uid — rather
279
538
  * than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
@@ -27,6 +27,10 @@ import { readFileSync } from "node:fs";
27
27
  import {
28
28
  isMain,
29
29
  lazyImport,
30
+ lazyImportErrorFor,
31
+ failedLazyPackages,
32
+ missingPackageMessage,
33
+ DEFAULT_MISSING_PACKAGE_REMEDY,
30
34
  registeredLazyModule,
31
35
  emitHookResponse,
32
36
  safeErrMessage,
@@ -50,6 +54,33 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
50
54
 
51
55
  const HOOK_NAME = "pretooluse-sanitize";
52
56
 
57
+ /**
58
+ * A host-supplied deny gate: given the PreToolUse input, the reason this call
59
+ * must be blocked, or null to let the pipeline continue. Hosts use these for
60
+ * policy the package has no view of (a required workflow step, a project-local
61
+ * rule); the package ships none.
62
+ * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
63
+ * => string | null | undefined} HostGate
64
+ */
65
+
66
+ /**
67
+ * The reasons this hook emits, as a table a host overrides. A host that knows
68
+ * which of ITS files wires the adapter, and what a reader should do about a
69
+ * failure, can say so — the package cannot, since it has no idea where it is
70
+ * installed.
71
+ * @type {Readonly<{
72
+ * unknownEvent: string,
73
+ * failed: (cause: string) => string,
74
+ * unparsable: (cause: string) => string,
75
+ * }>}
76
+ */
77
+ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
78
+ unknownEvent:
79
+ "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
80
+ failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
81
+ unparsable: (cause) => `PreToolUse input unparsable (fail-closed): ${cause}`,
82
+ });
83
+
53
84
  // Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
54
85
  // its doc for the fail-OPEN hazard of a bare static npm import); a failed load
55
86
  // leaves these bindings undefined, so the layer calls below throw into the CLI's
@@ -253,9 +284,19 @@ function assembleResponse({
253
284
  * fail-closed posture holds even when the adapter never loaded.
254
285
  * @param {import("agent-control-plane-core").ToolCallEvent} event
255
286
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
287
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
288
+ * messages are merged over the defaults, so a partial table is supported
256
289
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
257
290
  */
258
- export async function judgePreToolUseSanitize(event, rehydrate) {
291
+ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
292
+ const { gates = [] } = opts;
293
+ // MERGED over the defaults, never substituted for them. A host that overrides
294
+ // one field would otherwise leave the rest undefined, and the miss lands in
295
+ // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
296
+ // TypeError on a missing field escapes the handler, the hook exits with no
297
+ // stdout, and Claude reads a PreToolUse hook that produced no response as
298
+ // non-blocking — the fail-closed ask becomes a fail-OPEN pass.
299
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
259
300
  const { Decision, EventKind } = controlPlane();
260
301
  // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
261
302
  // would drive the pipeline with an empty event and no-op to ALLOW — a silent
@@ -264,15 +305,23 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
264
305
  // never a real call: deny-when-blind. Rewarding an unclassifiable payload with
265
306
  // a pass is the one incentive a gate must never create.
266
307
  if (event.event === EventKind.UNKNOWN)
267
- return {
268
- decision: Decision.DENY,
269
- reason:
270
- "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
271
- };
272
- const fields = await buildPreToolUseResponse(
273
- { tool_name: event.tool, tool_input: event.input },
274
- rehydrate,
275
- );
308
+ return { decision: Decision.DENY, reason: messages.unknownEvent };
309
+ // The session identity travels in `meta`, not alongside the tool input; a host
310
+ // gate keyed on the session (a once-per-session checkpoint) cannot tell two
311
+ // sessions apart without it.
312
+ const input = {
313
+ tool_name: event.tool,
314
+ tool_input: event.input,
315
+ session_id: event.meta?.session_id,
316
+ };
317
+ // Host gates run BEFORE any rewriting layer, because they decide whether the
318
+ // call may happen at all rather than what its input contains — and returning
319
+ // early keeps a denied call from also being reported as a sanitized one.
320
+ for (const gate of gates) {
321
+ const denyReason = gate(input);
322
+ if (denyReason) return { decision: Decision.DENY, reason: denyReason };
323
+ }
324
+ const fields = await buildPreToolUseResponse(input, rehydrate);
276
325
  if (fields === null) return { decision: Decision.ALLOW };
277
326
  /** @type {Record<string, unknown>} */
278
327
  const verdict = {
@@ -287,6 +336,42 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
287
336
  return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
288
337
  }
289
338
 
339
+ /**
340
+ * The dependency-load failure hiding behind a hook error, or "". A binding that
341
+ * never loaded surfaces at use time as a bare TypeError ("X is not a function")
342
+ * that names neither the package nor the remedy; when any lazily-loaded package
343
+ * has a recorded load error, name it — the failed set is derived from the
344
+ * loader's own records, so a future dependency is covered without editing a list
345
+ * here. An error already reporting a missing package (missingPackageError's
346
+ * `DEP_UNAVAILABLE` tag) gets no second copy.
347
+ * @param {unknown} err
348
+ * @param {string} [remedy] what a reader should run; hosts pass their own
349
+ * @param {() => string[]} [failedPackages]
350
+ * @param {(pkg: string) => unknown} [loadErrorFor]
351
+ * @returns {string}
352
+ */
353
+ export function depLoadHint(
354
+ err,
355
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
356
+ failedPackages = failedLazyPackages,
357
+ loadErrorFor = lazyImportErrorFor,
358
+ ) {
359
+ if (/** @type {{code?: unknown}} */ (err)?.code === "DEP_UNAVAILABLE")
360
+ return "";
361
+ // Only a TypeError. The recorded-failure set is process-wide and carries no
362
+ // link to THIS error, so naming a package from it is an inference — sound only
363
+ // for the failure this hint exists to explain, where an unloaded binding is
364
+ // called and V8 raises a TypeError ("X is not a function", "Cannot read
365
+ // properties of undefined"). Any other throw is a layer engine reporting its
366
+ // own problem, and appending a package name there sends the reader to a
367
+ // reinstall that fixes nothing.
368
+ if (!(err instanceof TypeError)) return "";
369
+ const [pkg] = failedPackages();
370
+ return pkg === undefined
371
+ ? ""
372
+ : ` ${missingPackageMessage(pkg, loadErrorFor(pkg), remedy)}`;
373
+ }
374
+
290
375
  /**
291
376
  * The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
292
377
  * WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
@@ -297,16 +382,22 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
297
382
  * so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
298
383
  * @param {boolean} parsedOk whether the input parsed before the failure
299
384
  * @param {unknown} err
385
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
300
386
  * @returns {Record<string, unknown>}
301
387
  */
302
- export function failClosedFields(parsedOk, err) {
388
+ export function failClosedFields(parsedOk, err, opts = {}) {
389
+ const { hint = depLoadHint(err) } = opts;
390
+ // Merged, not substituted — see judgePreToolUseSanitize. This is the call site
391
+ // where a missing field would throw out of the catch and fail OPEN.
392
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
393
+ const cause = `${safeErrMessage(err)}${hint}`;
303
394
  return {
304
395
  permissionDecision: parsedOk
305
396
  ? PermissionDecision.ASK
306
397
  : PermissionDecision.DENY,
307
398
  permissionDecisionReason: parsedOk
308
- ? `PreToolUse sanitization failed (fail-closed): ${safeErrMessage(err)}`
309
- : `PreToolUse input unparsable (fail-closed): ${safeErrMessage(err)}`,
399
+ ? messages.failed(cause)
400
+ : messages.unparsable(cause),
310
401
  };
311
402
  }
312
403
 
@@ -319,21 +410,35 @@ export function failClosedFields(parsedOk, err) {
319
410
  * Exported so a bundle entry (which must claim the CLI slot before this module
320
411
  * loads) can run the exact same wiring instead of duplicating the onError
321
412
  * posture.
413
+ * @param {{
414
+ * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
415
+ * gates?: HostGate[],
416
+ * remedy?: string,
417
+ * }} [opts]
322
418
  * @returns {Promise<void>}
323
419
  */
324
- export async function cliMain() {
325
- await runJudgeCli("pretooluse-sanitize", judgePreToolUseSanitize, {
326
- // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
327
- // hard-denies (adversary-inducible, no benefit to failing); any throw
328
- // after a clean parse — a layer engine down or the control-plane package
329
- // unavailable asks to keep a human in the loop. emitHookResponse renders
330
- // natively, so this posture holds even when the adapter never loaded.
331
- onError: (err, input) =>
332
- emitHookResponse(
333
- HookEvent.PRE_TOOL_USE,
334
- failClosedFields(input !== undefined, err),
335
- ),
336
- });
420
+ export async function cliMain(opts = {}) {
421
+ const { gates = [], remedy } = opts;
422
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
423
+ await runJudgeCli(
424
+ HOOK_NAME,
425
+ (event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
426
+ {
427
+ // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
428
+ // hard-denies (adversary-inducible, no benefit to failing); any throw
429
+ // after a clean parse — a layer engine down or the control-plane package
430
+ // unavailable asks to keep a human in the loop. emitHookResponse renders
431
+ // natively, so this posture holds even when the adapter never loaded.
432
+ onError: (err, input) =>
433
+ emitHookResponse(
434
+ HookEvent.PRE_TOOL_USE,
435
+ failClosedFields(input !== undefined, err, {
436
+ messages,
437
+ hint: depLoadHint(err, remedy),
438
+ }),
439
+ ),
440
+ },
441
+ );
337
442
  }
338
443
 
339
444
  if (isMain(import.meta.url)) {
@@ -28,6 +28,9 @@ import {
28
28
  errMessage,
29
29
  safeErrMessage,
30
30
  makeDeadline,
31
+ lazyImportErrorFor,
32
+ missingPackageMessage,
33
+ DEFAULT_MISSING_PACKAGE_REMEDY,
31
34
  HookEvent,
32
35
  } from "./lib/hook-io.mjs";
33
36
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
@@ -478,13 +481,6 @@ const FAIL_CLOSED_CONTEXT =
478
481
  "(replaced with a placeholder) to fail closed -- the unsanitized output was " +
479
482
  "not shown. Investigate the hook error before relying on this tool.";
480
483
 
481
- // The one cause that is a broken INSTALL rather than a broken hook: the
482
- // sanitizer's bindings are absent, so every subsequent tool call fails closed
483
- // with no visible cause. Name the remedy in the emission itself.
484
- const MISSING_DEPS_HINT =
485
- " The cause is a missing dependency (agent-sanitizer did not load), not a" +
486
- " hook defect: reinstall the plugin, then retry the tool call.";
487
-
488
484
  /**
489
485
  * Whether the sanitizer's bindings actually loaded. lazyImport swallows a
490
486
  * missing package and yields `{}`, so the absence shows up as an undefined
@@ -501,15 +497,22 @@ export function sanitizerDepsLoaded() {
501
497
  }
502
498
 
503
499
  /**
504
- * The model-facing note for a fail-closed emission, with the missing-dependency
505
- * remedy appended when the sanitizer's bindings are the thing that is absent.
500
+ * The model-facing note for a fail-closed emission. When the sanitizer's own
501
+ * bindings are what is absent a broken INSTALL rather than a broken hook, and
502
+ * otherwise invisible because every later tool call then fails closed with no
503
+ * stated cause — the recorded loader error and its remedy ride along. The text
504
+ * comes from missingPackageMessage so this hook, the PreToolUse gate and the
505
+ * prompt gate cannot drift apart on what a missing dependency reads like.
506
506
  * @param {() => boolean} [depsLoaded] injectable seam for testing
507
+ * @param {string} [remedy] what a reader should run; hosts pass their own
507
508
  * @returns {string}
508
509
  */
509
- export function failClosedContext(depsLoaded = sanitizerDepsLoaded) {
510
- return depsLoaded()
511
- ? FAIL_CLOSED_CONTEXT
512
- : FAIL_CLOSED_CONTEXT + MISSING_DEPS_HINT;
510
+ export function failClosedContext(
511
+ depsLoaded = sanitizerDepsLoaded,
512
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
513
+ ) {
514
+ if (depsLoaded()) return FAIL_CLOSED_CONTEXT;
515
+ return `${FAIL_CLOSED_CONTEXT} ${missingPackageMessage("agent-sanitizer", lazyImportErrorFor("agent-sanitizer"), remedy)}`;
513
516
  }
514
517
 
515
518
  /**
@@ -18,7 +18,12 @@
18
18
  * (cursor movement, erase, OSC title-set, DCS/APC/PM) still blocks, as do the
19
19
  * invisible-char thresholds, which are the actual web-paste payload defense.
20
20
  */
21
- import { readStdinJson, safeErrMessage, isMain } from "./lib/hook-io.mjs";
21
+ import {
22
+ readStdinJson,
23
+ safeErrMessage,
24
+ isMain,
25
+ missingPackageError,
26
+ } from "./lib/hook-io.mjs";
22
27
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
23
28
  import { trace, TraceEvent } from "./lib/trace.mjs";
24
29
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
@@ -33,10 +38,28 @@ export let classifyPrompt;
33
38
  /** @type {typeof import("agent-sanitizer").stripAnsiFully} */
34
39
  let stripAnsiFully;
35
40
 
36
- const BLOCK_CONTEXT =
37
- "User prompt blocked: payload-capable invisible/ANSI characters detected.";
38
- const SGR_NOTE =
39
- "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.";
41
+ /**
42
+ * The reasons this gate emits, as a table a host overrides. A host that knows
43
+ * which of ITS files wires the adapter, and what a reader should do about a
44
+ * failure, can say so the package cannot, since it has no idea where it is
45
+ * installed. Every field is a plain string or a string-returning function, so
46
+ * an override is auditable next to the default it replaces.
47
+ * @type {Readonly<{
48
+ * unknownEvent: string,
49
+ * blockContext: string,
50
+ * sgrNote: string,
51
+ * hookFailed: (cause: string) => string,
52
+ * }>}
53
+ */
54
+ export const USER_PROMPT_MESSAGES = Object.freeze({
55
+ unknownEvent: "User prompt blocked (fail-closed): unrecognized hook payload.",
56
+ blockContext:
57
+ "User prompt blocked: payload-capable invisible/ANSI characters detected.",
58
+ sgrNote:
59
+ "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
60
+ hookFailed: (cause) =>
61
+ `sanitize-user-prompt hook failed (fail-closed): ${cause}`,
62
+ });
40
63
 
41
64
  /* c8 ignore start — module-load boundary: the imports resolve in every real
42
65
  * run, and their failure (the package absent) can't be simulated in-process, so
@@ -67,9 +90,20 @@ try {
67
90
  * @param {import("agent-control-plane-core").ToolCallEvent} event
68
91
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
69
92
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
93
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
94
+ * merged over the defaults so a partial table can never leave a field unset
70
95
  * @returns {import("agent-control-plane-core").Verdict}
71
96
  */
72
- export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
97
+ export function judgeSanitizeUserPrompt(
98
+ event,
99
+ strip = stripAnsiFully,
100
+ overrides = USER_PROMPT_MESSAGES,
101
+ ) {
102
+ // MERGED over the defaults, never substituted for them — a host that overrides
103
+ // one field would otherwise leave the rest undefined. main() threads the same
104
+ // object into its onError, where a missing field throws out of the catch and
105
+ // the gate emits nothing, which the harness reads as a pass: fail OPEN.
106
+ const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
73
107
  const { Decision, EventKind } = controlPlane();
74
108
  // A payload the adapter cannot classify carries no readable prompt, so an
75
109
  // abstain would fail OPEN on harness contract drift; this gate's posture is
@@ -77,18 +111,14 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
77
111
  // channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
78
112
  // honors on UserPromptSubmit.)
79
113
  if (event.event === EventKind.UNKNOWN)
80
- return {
81
- decision: Decision.DENY,
82
- reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
83
- };
114
+ return { decision: Decision.DENY, reason: messages.unknownEvent };
84
115
  if (event.event !== EventKind.PROMPT_SUBMIT)
85
116
  return { decision: Decision.ALLOW };
86
117
  // The module-load guard: a missing stripper means agent-sanitizer never
87
118
  // loaded. Guarding on the stripper alone is sufficient — it loads AFTER
88
119
  // classifyPrompt in the same try, so a present stripper proves the classifier
89
120
  // loaded too.
90
- if (typeof strip !== "function")
91
- throw new Error("agent-sanitizer is unavailable");
121
+ if (typeof strip !== "function") throw missingPackageError("agent-sanitizer");
92
122
  // The contract guarantees a string here: every adapter normalizes the
93
123
  // prompt-submit input (Claude's parse coerces a missing/non-string prompt to
94
124
  // "" via asString), so a defensive typeof re-check is a dead branch.
@@ -97,13 +127,13 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
97
127
  const verdict = classifyPrompt(prompt, strip);
98
128
  if (verdict.action === "pass") return { decision: Decision.ALLOW };
99
129
  if (verdict.action === "note")
100
- return { decision: Decision.ALLOW, additional_context: SGR_NOTE };
130
+ return { decision: Decision.ALLOW, additional_context: messages.sgrNote };
101
131
  // block: carry the reason AND a context note — UserPromptSubmit can't rewrite
102
132
  // the prompt, so the context is the only forward signal about why it dropped.
103
133
  return {
104
134
  decision: Decision.DENY,
105
135
  reason: verdict.reason,
106
- additional_context: BLOCK_CONTEXT,
136
+ additional_context: messages.blockContext,
107
137
  };
108
138
  }
109
139
 
@@ -112,9 +142,19 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
112
142
  * @param {(chunk: string) => void} write
113
143
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
114
144
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
145
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
146
+ * merged over the defaults so a partial table can never leave a field unset
115
147
  * @returns {Promise<void>}
116
148
  */
117
- export async function main(read, write, strip = stripAnsiFully) {
149
+ export async function main(
150
+ read,
151
+ write,
152
+ strip = stripAnsiFully,
153
+ overrides = USER_PROMPT_MESSAGES,
154
+ ) {
155
+ // Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
156
+ // call site where a missing field would throw out of the catch and fail OPEN.
157
+ const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
118
158
  // Delegate the parse → judge → render → write contract to the shared
119
159
  // runJudgeCli so this hook doesn't re-implement the control-plane boundary:
120
160
  // runJudgeCli reads stdin BEFORE loading the control-plane package, so a
@@ -125,7 +165,7 @@ export async function main(read, write, strip = stripAnsiFully) {
125
165
  await runJudgeCli(
126
166
  "sanitize-user-prompt",
127
167
  (event) => {
128
- const verdict = judgeSanitizeUserPrompt(event, strip);
168
+ const verdict = judgeSanitizeUserPrompt(event, strip, messages);
129
169
  // Announce engagement on the trace channel like the other stdin hooks —
130
170
  // a prompt gate that silently stopped running is otherwise invisible.
131
171
  trace(TraceEvent.HOOK_RAN, {
@@ -146,7 +186,7 @@ export async function main(read, write, strip = stripAnsiFully) {
146
186
  write(
147
187
  JSON.stringify({
148
188
  decision: "block",
149
- reason: `sanitize-user-prompt hook failed (fail-closed): ${safeErrMessage(err)}`,
189
+ reason: messages.hookFailed(safeErrMessage(err)),
150
190
  }),
151
191
  ),
152
192
  },
@@ -7,7 +7,15 @@
7
7
  */
8
8
  import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
- import { isMain, lazyImport, writeFileNoFollow } from "./lib/hook-io.mjs";
10
+ import {
11
+ awaitLazyDependency,
12
+ hookgateMarkerPath,
13
+ isMain,
14
+ lazyImport,
15
+ markerIsTrusted,
16
+ probeSetupAlive,
17
+ writeFileNoFollow,
18
+ } from "./lib/hook-io.mjs";
11
19
  import {
12
20
  ALERT_FILE,
13
21
  ALERT_ACK_FILE,
@@ -17,9 +25,11 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
17
25
 
18
26
  // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
19
27
  // hazard of a bare static npm import — here the instruction files would load
20
- // UNSCANNED). A failed load leaves the bindings undefined, and cliMain's guard
21
- // below fails loud rather than silently passing.
22
- const {
28
+ // UNSCANNED). A failed load leaves the bindings undefined; on a cold container
29
+ // (node deps not yet installed) cliMain's guard below waits out session-setup
30
+ // before giving up, and fails loud rather than silently passing.
31
+ // `let`, not `const`: the cold-start poll re-binds these once the package loads.
32
+ let {
23
33
  LONG_RUN_RE,
24
34
  LONG_RUN_THRESHOLD,
25
35
  SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
@@ -29,6 +39,44 @@ const {
29
39
  await lazyImport("agent-sanitizer/invisible")
30
40
  );
31
41
 
42
+ /**
43
+ * Re-attempt the sanitizer import, waiting out an in-flight session-setup before
44
+ * giving up. On a cold container the node deps this hook needs are still being
45
+ * installed when SessionStart fires; without this wait `stripInvisible` is
46
+ * undefined, the scan is skipped, and the instruction files load UNSCANNED for the
47
+ * whole session (fail open) — silently. Reuses the control-plane poll (marker +
48
+ * PID liveness) so the wait bound matches every other cold-start-aware gate.
49
+ * @returns {Promise<boolean>} whether the sanitizer is now bound
50
+ */
51
+ async function ensureSanitizerLoaded() {
52
+ if (typeof stripInvisible === "function") return true;
53
+ /* c8 ignore start -- cold-start reload: only runs when the top-level
54
+ agent-sanitizer import above failed (node deps not yet installed), which
55
+ can't be simulated in-process or in the spawned-subprocess CLI run the tests
56
+ observe (the test env always has the deps, so the guard above early-returns).
57
+ The reload reuses awaitLazyDependency / markerIsTrusted /
58
+ probeSetupAlive, each unit-tested directly. */
59
+ const marker = hookgateMarkerPath();
60
+ const reloaded = await awaitLazyDependency({
61
+ tryImport: async () => {
62
+ const mod = await lazyImport("agent-sanitizer/invisible");
63
+ return typeof mod.stripInvisible === "function" ? mod : null;
64
+ },
65
+ markerPresent: () => markerIsTrusted(marker),
66
+ setupAlive: () => probeSetupAlive(marker),
67
+ });
68
+ if (!reloaded) return false;
69
+ ({
70
+ LONG_RUN_RE,
71
+ LONG_RUN_THRESHOLD,
72
+ SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
73
+ STRIP,
74
+ stripInvisible,
75
+ } = /** @type {typeof import("agent-sanitizer/invisible")} */ (reloaded));
76
+ return true;
77
+ /* c8 ignore stop */
78
+ }
79
+
32
80
  // Decoder
33
81
 
34
82
  /**
@@ -229,14 +277,15 @@ export async function cliMain() {
229
277
  /* c8 ignore start -- fail-closed module-load guard: only reachable when the
230
278
  agent-sanitizer import above failed, which can't be simulated in the
231
279
  spawned-subprocess CLI run the tests observe. */
232
- if (typeof stripInvisible !== "function") {
280
+ if (!(await ensureSanitizerLoaded())) {
233
281
  // Emit the engagement event with a "skipped" outcome so the loss is LOUD on
234
282
  // the trace channel — a scan that never ran is otherwise invisible, and the
235
283
  // downstream PreToolUse sanitize gate then passes cleanly all session.
236
284
  trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
237
285
  process.stderr.write(
238
- "scan-invisible-chars: agent-sanitizer failed to load; instruction " +
239
- "files were NOT scanned for hidden Unicode.\n",
286
+ "scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
287
+ "installed and session-setup did not finish in time); instruction " +
288
+ "files were NOT scanned for hidden Unicode. Run `pnpm install`.\n",
240
289
  );
241
290
  process.exit(1);
242
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.8.0",
3
+ "version": "2.9.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": {
@@ -153,6 +153,34 @@
153
153
  "./claude-hooks/lib/control-plane": {
154
154
  "types": "./types/claude-hooks/lib/control-plane.d.mts",
155
155
  "default": "./claude-hooks/lib/control-plane.mjs"
156
+ },
157
+ "./claude-hooks/lib/invisible-alert": {
158
+ "types": "./types/claude-hooks/lib/invisible-alert.d.mts",
159
+ "default": "./claude-hooks/lib/invisible-alert.mjs"
160
+ },
161
+ "./claude-hooks/lib/authored-content": {
162
+ "types": "./types/claude-hooks/lib/authored-content.d.mts",
163
+ "default": "./claude-hooks/lib/authored-content.mjs"
164
+ },
165
+ "./claude-hooks/lib/env-config": {
166
+ "types": "./types/claude-hooks/lib/env-config.d.mts",
167
+ "default": "./claude-hooks/lib/env-config.mjs"
168
+ },
169
+ "./claude-hooks/lib/secret-annotate": {
170
+ "types": "./types/claude-hooks/lib/secret-annotate.d.mts",
171
+ "default": "./claude-hooks/lib/secret-annotate.mjs"
172
+ },
173
+ "./claude-hooks/lib/reveal": {
174
+ "types": "./types/claude-hooks/lib/reveal.d.mts",
175
+ "default": "./claude-hooks/lib/reveal.mjs"
176
+ },
177
+ "./claude-hooks/lib/redactor-client": {
178
+ "types": "./types/claude-hooks/lib/redactor-client.d.mts",
179
+ "default": "./claude-hooks/lib/redactor-client.mjs"
180
+ },
181
+ "./claude-hooks/lib/trace": {
182
+ "types": "./types/claude-hooks/lib/trace.d.mts",
183
+ "default": "./claude-hooks/lib/trace.mjs"
156
184
  }
157
185
  },
158
186
  "files": [
@@ -62,6 +62,47 @@ export function registeredLazyModule(specifier: string): Record<string, any> | u
62
62
  * @returns {Promise<Record<string, any>>}
63
63
  */
64
64
  export function lazyImport(specifier: string): Promise<Record<string, any>>;
65
+ /**
66
+ * The most recently recorded load error for `pkg` under any of its specifiers —
67
+ * the bare package or a subpath export (`pkg/output`, `pkg/invisible`) — or
68
+ * undefined when none is recorded. Hooks import a package through several
69
+ * subpaths; any one of them names why the package is absent, and the newest
70
+ * record reflects the current failure when they differ.
71
+ * @param {string} pkg
72
+ * @returns {unknown}
73
+ */
74
+ export function lazyImportErrorFor(pkg: string): unknown;
75
+ /**
76
+ * Package names (never relative-path specifiers) with a recorded load error,
77
+ * newest first — so a fail-closed reason can name whichever dependency actually
78
+ * failed instead of consulting a hardcoded package list.
79
+ * @returns {string[]}
80
+ */
81
+ export function failedLazyPackages(): string[];
82
+ /**
83
+ * The fail-closed reason for a package a hook could not load: the recorded
84
+ * loader error plus the remedy. The cause is scrubbed (it is spliced into
85
+ * reasons shown to user and model) and its cap is COMPUTED so that
86
+ * prefix + cause + remedy always fits the downstream 300-char safeErrMessage
87
+ * re-scrub — the remedy can never be truncated off, whatever the package name.
88
+ * A remedy that alone exceeds that budget (roughly 260 characters) leaves the
89
+ * cause nothing to spend and still overruns; keep host remedies to a sentence.
90
+ * @param {string} pkg
91
+ * @param {unknown} [err]
92
+ * @param {string} [remedy]
93
+ * @returns {string}
94
+ */
95
+ export function missingPackageMessage(pkg: string, err?: unknown, remedy?: string): string;
96
+ /**
97
+ * {@link missingPackageMessage} as a throwable, tagged `code: "DEP_UNAVAILABLE"`
98
+ * so downstream reason-builders can recognize it structurally and not append a
99
+ * second copy of the same cause.
100
+ * @param {string} pkg
101
+ * @param {unknown} [err]
102
+ * @param {string} [remedy]
103
+ * @returns {Error}
104
+ */
105
+ export function missingPackageError(pkg: string, err?: unknown, remedy?: string): Error;
65
106
  /**
66
107
  * A monotonic wall-clock budget shared across one hook run's downstream blocking
67
108
  * calls. `remainingMs()` returns the milliseconds left until the budget is spent
@@ -126,6 +167,77 @@ export function safeErrMessage(err: unknown, cap?: number): string;
126
167
  * @returns {void}
127
168
  */
128
169
  export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
170
+ /**
171
+ * Path of the cold-start in-flight marker a host's setup script writes
172
+ * SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
173
+ * and removes once the hook dependencies are provisioned. A hook that fires
174
+ * before setup finishes finds the marker and WAITS for its dependency rather
175
+ * than failing closed on it — so the first turn is merely delayed, never
176
+ * blocked, for as long as setup is still alive (the PID lets the hook tell a
177
+ * live install from a stale marker left by a killed setup). Derived purely from
178
+ * the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
179
+ * canonicalization — the two must produce byte-identical paths), so no env has
180
+ * to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
181
+ * setup ran → nothing to wait on).
182
+ * @param {string | undefined} [projectDir]
183
+ * @param {string | undefined} [runtimeDir]
184
+ * @returns {string | null}
185
+ */
186
+ export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
187
+ /**
188
+ * Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
189
+ * probes liveness without signalling: it throws ESRCH once the process is gone (a
190
+ * killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
191
+ * ours (still alive). An unreadable / not-yet-written marker is treated as alive —
192
+ * favouring a brief wait over a premature give-up during setup's write race. A null
193
+ * markerPath (no project dir → no setup to wait on) reads as alive so the caller's
194
+ * own grace/ceiling bound governs.
195
+ * @param {string | null} markerPath
196
+ * @returns {boolean}
197
+ */
198
+ export function probeSetupAlive(markerPath: string | null): boolean;
199
+ /**
200
+ * Resolve a lazily-loaded dependency, blocking through the cold-start window while
201
+ * setup is still installing it. Returns the loaded value, or null once it gives up
202
+ * (the caller leaves its bindings undefined so the hook fails closed). It waits for
203
+ * as long as setup is genuinely alive, so a slow install is never cut off; the only
204
+ * bound on that wait is a backstop ceiling that stays under the hook's harness
205
+ * timeout — a hook killed for running over is a fail-OPEN, the opposite of what a
206
+ * gate wants. The give-up cases are the honest ones (setup finished/died without the
207
+ * dep, or no setup at all), so a genuinely-absent dep fails closed fast, never after
208
+ * a long block:
209
+ * - import succeeds → return immediately (warm session: no wait).
210
+ * - marker present AND setup alive → setup is working; wait it out (ceilingMs is a
211
+ * backstop only, for a hung-but-alive setup).
212
+ * - was installing, now not (marker cleared, or a stale marker from a killed setup)
213
+ * → settleMs grace for a just-orphaned install to
214
+ * land, then give up: the dep is absent.
215
+ * - no live setup ever seen → wait only graceMs (tolerating setup not having
216
+ * written the marker yet), then give up.
217
+ * @param {{
218
+ * tryImport: () => Promise<object | null>,
219
+ * markerPresent: () => boolean,
220
+ * setupAlive: () => boolean,
221
+ * now?: () => number,
222
+ * sleep?: (ms: number) => Promise<void>,
223
+ * graceMs?: number,
224
+ * settleMs?: number,
225
+ * ceilingMs?: number,
226
+ * intervalMs?: number,
227
+ * }} deps
228
+ * @returns {Promise<object | null>}
229
+ */
230
+ export function awaitLazyDependency({ tryImport, markerPresent, setupAlive, now, sleep, graceMs, settleMs, ceilingMs, intervalMs, }: {
231
+ tryImport: () => Promise<object | null>;
232
+ markerPresent: () => boolean;
233
+ setupAlive: () => boolean;
234
+ now?: () => number;
235
+ sleep?: (ms: number) => Promise<void>;
236
+ graceMs?: number;
237
+ settleMs?: number;
238
+ ceilingMs?: number;
239
+ intervalMs?: number;
240
+ }): Promise<object | null>;
129
241
  /**
130
242
  * Is the file at `path` one WE wrote — a regular file owned by this uid — rather
131
243
  * than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
@@ -194,3 +306,10 @@ export const PermissionDecision: Readonly<{
194
306
  * and take its own fail-closed output down with it.
195
307
  */
196
308
  export const MAX_STDIN_BYTES: number;
309
+ /**
310
+ * The remedy {@link missingPackageMessage} states when the host does not supply
311
+ * one of its own. A host whose install has a specific entry point (a setup
312
+ * script, a devcontainer rebuild) passes that instead, so the reason names the
313
+ * command the reader should actually run.
314
+ */
315
+ export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
@@ -19,9 +19,29 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
19
19
  * fail-closed posture holds even when the adapter never loaded.
20
20
  * @param {import("agent-control-plane-core").ToolCallEvent} event
21
21
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
22
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
23
+ * messages are merged over the defaults, so a partial table is supported
22
24
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
23
25
  */
24
- export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>): Promise<import("agent-control-plane-core").Verdict>;
26
+ export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, opts?: {
27
+ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
28
+ gates?: HostGate[];
29
+ }): Promise<import("agent-control-plane-core").Verdict>;
30
+ /**
31
+ * The dependency-load failure hiding behind a hook error, or "". A binding that
32
+ * never loaded surfaces at use time as a bare TypeError ("X is not a function")
33
+ * that names neither the package nor the remedy; when any lazily-loaded package
34
+ * has a recorded load error, name it — the failed set is derived from the
35
+ * loader's own records, so a future dependency is covered without editing a list
36
+ * here. An error already reporting a missing package (missingPackageError's
37
+ * `DEP_UNAVAILABLE` tag) gets no second copy.
38
+ * @param {unknown} err
39
+ * @param {string} [remedy] what a reader should run; hosts pass their own
40
+ * @param {() => string[]} [failedPackages]
41
+ * @param {(pkg: string) => unknown} [loadErrorFor]
42
+ * @returns {string}
43
+ */
44
+ export function depLoadHint(err: unknown, remedy?: string, failedPackages?: () => string[], loadErrorFor?: (pkg: string) => unknown): string;
25
45
  /**
26
46
  * The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
27
47
  * WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
@@ -32,16 +52,64 @@ export function judgePreToolUseSanitize(event: import("agent-control-plane-core"
32
52
  * so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
33
53
  * @param {boolean} parsedOk whether the input parsed before the failure
34
54
  * @param {unknown} err
55
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
35
56
  * @returns {Record<string, unknown>}
36
57
  */
37
- export function failClosedFields(parsedOk: boolean, err: unknown): Record<string, unknown>;
58
+ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
59
+ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
60
+ hint?: string;
61
+ }): Record<string, unknown>;
38
62
  /**
39
63
  * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
40
64
  * Exported so a bundle entry (which must claim the CLI slot before this module
41
65
  * loads) can run the exact same wiring instead of duplicating the onError
42
66
  * posture.
67
+ * @param {{
68
+ * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
69
+ * gates?: HostGate[],
70
+ * remedy?: string,
71
+ * }} [opts]
43
72
  * @returns {Promise<void>}
44
73
  */
45
- export function cliMain(): Promise<void>;
74
+ export function cliMain(opts?: {
75
+ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
76
+ gates?: HostGate[];
77
+ remedy?: string;
78
+ }): Promise<void>;
79
+ /**
80
+ * A host-supplied deny gate: given the PreToolUse input, the reason this call
81
+ * must be blocked, or null to let the pipeline continue. Hosts use these for
82
+ * policy the package has no view of (a required workflow step, a project-local
83
+ * rule); the package ships none.
84
+ * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
85
+ * => string | null | undefined} HostGate
86
+ */
87
+ /**
88
+ * The reasons this hook emits, as a table a host overrides. A host that knows
89
+ * which of ITS files wires the adapter, and what a reader should do about a
90
+ * failure, can say so — the package cannot, since it has no idea where it is
91
+ * installed.
92
+ * @type {Readonly<{
93
+ * unknownEvent: string,
94
+ * failed: (cause: string) => string,
95
+ * unparsable: (cause: string) => string,
96
+ * }>}
97
+ */
98
+ export const PRE_TOOL_USE_MESSAGES: Readonly<{
99
+ unknownEvent: string;
100
+ failed: (cause: string) => string;
101
+ unparsable: (cause: string) => string;
102
+ }>;
103
+ /**
104
+ * A host-supplied deny gate: given the PreToolUse input, the reason this call
105
+ * must be blocked, or null to let the pipeline continue. Hosts use these for
106
+ * policy the package has no view of (a required workflow step, a project-local
107
+ * rule); the package ships none.
108
+ */
109
+ export type HostGate = (input: {
110
+ tool_name: string | null;
111
+ tool_input: any;
112
+ session_id?: string;
113
+ }) => string | null | undefined;
46
114
  declare const rehydrateRedacted: typeof import("agent-sanitizer/rehydrate").rehydrateRedacted;
47
115
  export {};
@@ -118,12 +118,17 @@ export function failClosedReplacement(input: any, message: string): any;
118
118
  */
119
119
  export function sanitizerDepsLoaded(): boolean;
120
120
  /**
121
- * The model-facing note for a fail-closed emission, with the missing-dependency
122
- * remedy appended when the sanitizer's bindings are the thing that is absent.
121
+ * The model-facing note for a fail-closed emission. When the sanitizer's own
122
+ * bindings are what is absent a broken INSTALL rather than a broken hook, and
123
+ * otherwise invisible because every later tool call then fails closed with no
124
+ * stated cause — the recorded loader error and its remedy ride along. The text
125
+ * comes from missingPackageMessage so this hook, the PreToolUse gate and the
126
+ * prompt gate cannot drift apart on what a missing dependency reads like.
123
127
  * @param {() => boolean} [depsLoaded] injectable seam for testing
128
+ * @param {string} [remedy] what a reader should run; hosts pass their own
124
129
  * @returns {string}
125
130
  */
126
- export function failClosedContext(depsLoaded?: () => boolean): string;
131
+ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string): string;
127
132
  /**
128
133
  * Emit a fail-closed PostToolUse response, robust to the suppression itself
129
134
  * throwing. The shape-matching replacement walks `input.tool_response` and the
@@ -8,16 +8,39 @@
8
8
  * @param {import("agent-control-plane-core").ToolCallEvent} event
9
9
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
10
10
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
11
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
12
+ * merged over the defaults so a partial table can never leave a field unset
11
13
  * @returns {import("agent-control-plane-core").Verdict}
12
14
  */
13
- export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null): import("agent-control-plane-core").Verdict;
15
+ export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): import("agent-control-plane-core").Verdict;
14
16
  /**
15
17
  * @param {() => Promise<any> | any} read
16
18
  * @param {(chunk: string) => void} write
17
19
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
18
20
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
21
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
22
+ * merged over the defaults so a partial table can never leave a field unset
19
23
  * @returns {Promise<void>}
20
24
  */
21
- export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null): Promise<void>;
25
+ export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): Promise<void>;
22
26
  /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
23
27
  export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
28
+ /**
29
+ * The reasons this gate emits, as a table a host overrides. A host that knows
30
+ * which of ITS files wires the adapter, and what a reader should do about a
31
+ * failure, can say so — the package cannot, since it has no idea where it is
32
+ * installed. Every field is a plain string or a string-returning function, so
33
+ * an override is auditable next to the default it replaces.
34
+ * @type {Readonly<{
35
+ * unknownEvent: string,
36
+ * blockContext: string,
37
+ * sgrNote: string,
38
+ * hookFailed: (cause: string) => string,
39
+ * }>}
40
+ */
41
+ export const USER_PROMPT_MESSAGES: Readonly<{
42
+ unknownEvent: string;
43
+ blockContext: string;
44
+ sgrNote: string;
45
+ hookFailed: (cause: string) => string;
46
+ }>;
@@ -42,9 +42,9 @@ export function scanFile(filePath: string): Array<{
42
42
  }>;
43
43
  import { ALERT_FILE } from "./lib/invisible-alert.mjs";
44
44
  import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
45
- export const LONG_RUN_RE: RegExp;
46
- export const LONG_RUN_THRESHOLD: 10;
47
- export const TOTAL_INVISIBLE_THRESHOLD: 30;
45
+ export let LONG_RUN_RE: RegExp;
46
+ export let LONG_RUN_THRESHOLD: 10;
47
+ export let TOTAL_INVISIBLE_THRESHOLD: 30;
48
48
  /**
49
49
  * @param {Array<{
50
50
  * file: string,