agent-sanitizer 2.8.0 → 2.9.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.
@@ -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
@@ -34,6 +34,7 @@ const LAZY_LOADERS = {
34
34
  "agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
35
35
  "agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
36
36
  "agent-sanitizer/output": () => import("agent-sanitizer/output"),
37
+ "agent-sanitizer/prompt": () => import("agent-sanitizer/prompt"),
37
38
  "agent-sanitizer/rehydrate": () => import("agent-sanitizer/rehydrate"),
38
39
  "namespace-guard": () => import("namespace-guard"),
39
40
  };
@@ -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,39 @@ 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
+ * remedy: string,
76
+ * }>}
77
+ */
78
+ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
79
+ unknownEvent:
80
+ "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
81
+ failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
82
+ unparsable: (cause) => `PreToolUse input unparsable (fail-closed): ${cause}`,
83
+ // What a reader should run when a dependency is what is missing. It rides in
84
+ // this table rather than a separate argument because it is host text exactly
85
+ // like the reasons above, and one channel means a host cannot supply its
86
+ // wording in one place and forget it in the other.
87
+ remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
88
+ });
89
+
53
90
  // Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
54
91
  // its doc for the fail-OPEN hazard of a bare static npm import); a failed load
55
92
  // leaves these bindings undefined, so the layer calls below throw into the CLI's
@@ -253,9 +290,19 @@ function assembleResponse({
253
290
  * fail-closed posture holds even when the adapter never loaded.
254
291
  * @param {import("agent-control-plane-core").ToolCallEvent} event
255
292
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
293
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
294
+ * messages are merged over the defaults, so a partial table is supported
256
295
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
257
296
  */
258
- export async function judgePreToolUseSanitize(event, rehydrate) {
297
+ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
298
+ const { gates = [] } = opts;
299
+ // MERGED over the defaults, never substituted for them. A host that overrides
300
+ // one field would otherwise leave the rest undefined, and the miss lands in
301
+ // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
302
+ // TypeError on a missing field escapes the handler, the hook exits with no
303
+ // stdout, and Claude reads a PreToolUse hook that produced no response as
304
+ // non-blocking — the fail-closed ask becomes a fail-OPEN pass.
305
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
259
306
  const { Decision, EventKind } = controlPlane();
260
307
  // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
261
308
  // would drive the pipeline with an empty event and no-op to ALLOW — a silent
@@ -264,15 +311,23 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
264
311
  // never a real call: deny-when-blind. Rewarding an unclassifiable payload with
265
312
  // a pass is the one incentive a gate must never create.
266
313
  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
- );
314
+ return { decision: Decision.DENY, reason: messages.unknownEvent };
315
+ // The session identity travels in `meta`, not alongside the tool input; a host
316
+ // gate keyed on the session (a once-per-session checkpoint) cannot tell two
317
+ // sessions apart without it.
318
+ const input = {
319
+ tool_name: event.tool,
320
+ tool_input: event.input,
321
+ session_id: event.meta?.session_id,
322
+ };
323
+ // Host gates run BEFORE any rewriting layer, because they decide whether the
324
+ // call may happen at all rather than what its input contains — and returning
325
+ // early keeps a denied call from also being reported as a sanitized one.
326
+ for (const gate of gates) {
327
+ const denyReason = gate(input);
328
+ if (denyReason) return { decision: Decision.DENY, reason: denyReason };
329
+ }
330
+ const fields = await buildPreToolUseResponse(input, rehydrate);
276
331
  if (fields === null) return { decision: Decision.ALLOW };
277
332
  /** @type {Record<string, unknown>} */
278
333
  const verdict = {
@@ -287,6 +342,42 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
287
342
  return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
288
343
  }
289
344
 
345
+ /**
346
+ * The dependency-load failure hiding behind a hook error, or "". A binding that
347
+ * never loaded surfaces at use time as a bare TypeError ("X is not a function")
348
+ * that names neither the package nor the remedy; when any lazily-loaded package
349
+ * has a recorded load error, name it — the failed set is derived from the
350
+ * loader's own records, so a future dependency is covered without editing a list
351
+ * here. An error already reporting a missing package (missingPackageError's
352
+ * `DEP_UNAVAILABLE` tag) gets no second copy.
353
+ * @param {unknown} err
354
+ * @param {string} [remedy] what a reader should run; hosts pass their own
355
+ * @param {() => string[]} [failedPackages]
356
+ * @param {(pkg: string) => unknown} [loadErrorFor]
357
+ * @returns {string}
358
+ */
359
+ export function depLoadHint(
360
+ err,
361
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
362
+ failedPackages = failedLazyPackages,
363
+ loadErrorFor = lazyImportErrorFor,
364
+ ) {
365
+ if (/** @type {{code?: unknown}} */ (err)?.code === "DEP_UNAVAILABLE")
366
+ return "";
367
+ // Only a TypeError. The recorded-failure set is process-wide and carries no
368
+ // link to THIS error, so naming a package from it is an inference — sound only
369
+ // for the failure this hint exists to explain, where an unloaded binding is
370
+ // called and V8 raises a TypeError ("X is not a function", "Cannot read
371
+ // properties of undefined"). Any other throw is a layer engine reporting its
372
+ // own problem, and appending a package name there sends the reader to a
373
+ // reinstall that fixes nothing.
374
+ if (!(err instanceof TypeError)) return "";
375
+ const [pkg] = failedPackages();
376
+ return pkg === undefined
377
+ ? ""
378
+ : ` ${missingPackageMessage(pkg, loadErrorFor(pkg), remedy)}`;
379
+ }
380
+
290
381
  /**
291
382
  * The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
292
383
  * WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
@@ -297,16 +388,22 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
297
388
  * so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
298
389
  * @param {boolean} parsedOk whether the input parsed before the failure
299
390
  * @param {unknown} err
391
+ * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
300
392
  * @returns {Record<string, unknown>}
301
393
  */
302
- export function failClosedFields(parsedOk, err) {
394
+ export function failClosedFields(parsedOk, err, opts = {}) {
395
+ const { hint = depLoadHint(err) } = opts;
396
+ // Merged, not substituted — see judgePreToolUseSanitize. This is the call site
397
+ // where a missing field would throw out of the catch and fail OPEN.
398
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
399
+ const cause = `${safeErrMessage(err)}${hint}`;
303
400
  return {
304
401
  permissionDecision: parsedOk
305
402
  ? PermissionDecision.ASK
306
403
  : PermissionDecision.DENY,
307
404
  permissionDecisionReason: parsedOk
308
- ? `PreToolUse sanitization failed (fail-closed): ${safeErrMessage(err)}`
309
- : `PreToolUse input unparsable (fail-closed): ${safeErrMessage(err)}`,
405
+ ? messages.failed(cause)
406
+ : messages.unparsable(cause),
310
407
  };
311
408
  }
312
409
 
@@ -319,21 +416,34 @@ export function failClosedFields(parsedOk, err) {
319
416
  * Exported so a bundle entry (which must claim the CLI slot before this module
320
417
  * loads) can run the exact same wiring instead of duplicating the onError
321
418
  * posture.
419
+ * @param {{
420
+ * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
421
+ * gates?: HostGate[],
422
+ * }} [opts]
322
423
  * @returns {Promise<void>}
323
424
  */
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
- });
425
+ export async function cliMain(opts = {}) {
426
+ const { gates = [] } = opts;
427
+ const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
428
+ await runJudgeCli(
429
+ HOOK_NAME,
430
+ (event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
431
+ {
432
+ // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
433
+ // hard-denies (adversary-inducible, no benefit to failing); any throw
434
+ // after a clean parse — a layer engine down or the control-plane package
435
+ // unavailable asks to keep a human in the loop. emitHookResponse renders
436
+ // natively, so this posture holds even when the adapter never loaded.
437
+ onError: (err, input) =>
438
+ emitHookResponse(
439
+ HookEvent.PRE_TOOL_USE,
440
+ failClosedFields(input !== undefined, err, {
441
+ messages,
442
+ hint: depLoadHint(err, messages.remedy),
443
+ }),
444
+ ),
445
+ },
446
+ );
337
447
  }
338
448
 
339
449
  if (isMain(import.meta.url)) {