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.
@@ -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";
@@ -192,6 +195,11 @@ async function redactSecrets(text, webIngress = false, deadline) {
192
195
  * @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
193
196
  * Awaited once per judged event that carried a tool response, with the output
194
197
  * the model will actually see.
198
+ * @property {string} [remedy]
199
+ * What a reader should run when the sanitizer's own bindings are what is
200
+ * missing. This hook's host channel is `ext`, where the other two gates use a
201
+ * frozen message table; either way it is one channel per gate, so a host
202
+ * cannot supply its wording somewhere the fail-closed context never reads.
195
203
  */
196
204
 
197
205
  /**
@@ -478,13 +486,6 @@ const FAIL_CLOSED_CONTEXT =
478
486
  "(replaced with a placeholder) to fail closed -- the unsanitized output was " +
479
487
  "not shown. Investigate the hook error before relying on this tool.";
480
488
 
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
489
  /**
489
490
  * Whether the sanitizer's bindings actually loaded. lazyImport swallows a
490
491
  * missing package and yields `{}`, so the absence shows up as an undefined
@@ -501,15 +502,22 @@ export function sanitizerDepsLoaded() {
501
502
  }
502
503
 
503
504
  /**
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.
505
+ * The model-facing note for a fail-closed emission. When the sanitizer's own
506
+ * bindings are what is absent — a broken INSTALL rather than a broken hook, and
507
+ * otherwise invisible because every later tool call then fails closed with no
508
+ * stated cause — the recorded loader error and its remedy ride along. The text
509
+ * comes from missingPackageMessage so this hook, the PreToolUse gate and the
510
+ * prompt gate cannot drift apart on what a missing dependency reads like.
506
511
  * @param {() => boolean} [depsLoaded] injectable seam for testing
512
+ * @param {string} [remedy] what a reader should run; hosts pass their own
507
513
  * @returns {string}
508
514
  */
509
- export function failClosedContext(depsLoaded = sanitizerDepsLoaded) {
510
- return depsLoaded()
511
- ? FAIL_CLOSED_CONTEXT
512
- : FAIL_CLOSED_CONTEXT + MISSING_DEPS_HINT;
515
+ export function failClosedContext(
516
+ depsLoaded = sanitizerDepsLoaded,
517
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
518
+ ) {
519
+ if (depsLoaded()) return FAIL_CLOSED_CONTEXT;
520
+ return `${FAIL_CLOSED_CONTEXT} ${missingPackageMessage("agent-sanitizer", lazyImportErrorFor("agent-sanitizer"), remedy)}`;
513
521
  }
514
522
 
515
523
  /**
@@ -525,14 +533,19 @@ export function failClosedContext(depsLoaded = sanitizerDepsLoaded) {
525
533
  * @param {any} input parsed hook input, or undefined if parsing threw
526
534
  * @param {string} message
527
535
  * @param {(fields: Record<string, unknown>) => void} [emit]
536
+ * @param {string} [remedy] what a reader should run; hosts pass their own
528
537
  * @returns {void}
529
538
  */
530
539
  export function emitFailClosed(
531
540
  input,
532
541
  message,
533
542
  emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
543
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
534
544
  ) {
535
- const additionalContext = failClosedContext();
545
+ // Threaded rather than defaulted here: this is the ONLY production caller of
546
+ // failClosedContext, so a remedy it does not pass is a remedy no host can ever
547
+ // reach — the parameter would be live only from tests.
548
+ const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
536
549
  try {
537
550
  emit({
538
551
  updatedToolOutput: failClosedReplacement(input, message),
@@ -771,6 +784,8 @@ export async function cliMain(ext = {}) {
771
784
  "[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
772
785
  safeErrMessage(err) +
773
786
  "]",
787
+ undefined,
788
+ ext.remedy,
774
789
  ),
775
790
  },
776
791
  );
@@ -18,7 +18,14 @@
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
+ lazyImport,
26
+ missingPackageError,
27
+ DEFAULT_MISSING_PACKAGE_REMEDY,
28
+ } from "./lib/hook-io.mjs";
22
29
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
23
30
  import { trace, TraceEvent } from "./lib/trace.mjs";
24
31
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
@@ -26,34 +33,61 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
26
33
  // import, never a bare top-level `import … from "…"`: a static npm import
27
34
  // resolves before any try/catch, so a missing node_modules would crash this hook
28
35
  // at load and let the prompt through UNSANITIZED (fail-open). A failed load
29
- // leaves the bindings undefined, which main()'s typeof guard turns into a
30
- // fail-closed block.
36
+ // leaves that binding undefined, which the judge's typeof guards turn into a
37
+ // fail-closed block — one guard each, since the two loads succeed or fail
38
+ // independently.
31
39
  /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
32
40
  export let classifyPrompt;
33
41
  /** @type {typeof import("agent-sanitizer").stripAnsiFully} */
34
42
  let stripAnsiFully;
35
43
 
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.";
44
+ /**
45
+ * The reasons this gate emits, as a table a host overrides. A host that knows
46
+ * which of ITS files wires the adapter, and what a reader should do about a
47
+ * failure, can say so — the package cannot, since it has no idea where it is
48
+ * installed. Every field is a plain string or a string-returning function, so
49
+ * an override is auditable next to the default it replaces.
50
+ * @type {Readonly<{
51
+ * unknownEvent: string,
52
+ * blockContext: string,
53
+ * sgrNote: string,
54
+ * hookFailed: (cause: string) => string,
55
+ * remedy: string,
56
+ * }>}
57
+ */
58
+ export const USER_PROMPT_MESSAGES = Object.freeze({
59
+ unknownEvent: "User prompt blocked (fail-closed): unrecognized hook payload.",
60
+ blockContext:
61
+ "User prompt blocked: payload-capable invisible/ANSI characters detected.",
62
+ sgrNote:
63
+ "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
64
+ hookFailed: (cause) =>
65
+ `sanitize-user-prompt hook failed (fail-closed): ${cause}`,
66
+ // What a reader should run when the package itself is what is missing. It
67
+ // rides in this table rather than a separate argument because it is host text
68
+ // exactly like the reasons above, and one channel means a host cannot supply
69
+ // its wording in one place and forget it in the other.
70
+ remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
71
+ });
40
72
 
41
73
  /* c8 ignore start — module-load boundary: the imports resolve in every real
42
74
  * run, and their failure (the package absent) can't be simulated in-process, so
43
- * neither arm is observable to the in-process tests. main()'s typeof guard
75
+ * neither arm is observable to the in-process tests. The judge's typeof guard
44
76
  * converts an undefined stripper into a fail-closed block — that guard IS tested. */
45
77
  // Stryker disable all
46
- try {
47
- // The /prompt subpath is imported first: if it fails, the catch fires before
48
- // stripAnsiFully is assigned, so a half-load can never leave the stripper set
49
- // while the classifier is missing (main guards on the stripper alone).
50
- ({ classifyPrompt } = await import("agent-sanitizer/prompt"));
51
- ({ stripAnsiFully } = await import("agent-sanitizer"));
52
- } catch {
53
- // Leave classifyPrompt/stripAnsiFully undefined so main()'s typeof guard fails
54
- // closed — the prompt is blocked, never passed through with the package
55
- // half-loaded.
56
- }
78
+ // lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
79
+ // loader error, which is the only thing that can tell a missing install apart
80
+ // from a present package missing an export. Discarding it leaves
81
+ // missingPackageError with nothing to report but a guess.
82
+ // The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
83
+ // failure, so either binding can still be undefined here — which is exactly what
84
+ // the judge's typeof guard turns into a fail-closed block.
85
+ ({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
86
+ await lazyImport("agent-sanitizer/prompt")
87
+ ));
88
+ ({ stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
89
+ await lazyImport("agent-sanitizer")
90
+ ));
57
91
  // Stryker restore all
58
92
  /* c8 ignore stop */
59
93
 
@@ -67,9 +101,20 @@ try {
67
101
  * @param {import("agent-control-plane-core").ToolCallEvent} event
68
102
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
69
103
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
104
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
105
+ * merged over the defaults so a partial table can never leave a field unset
70
106
  * @returns {import("agent-control-plane-core").Verdict}
71
107
  */
72
- export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
108
+ export function judgeSanitizeUserPrompt(
109
+ event,
110
+ strip = stripAnsiFully,
111
+ overrides = USER_PROMPT_MESSAGES,
112
+ ) {
113
+ // MERGED over the defaults, never substituted for them — a host that overrides
114
+ // one field would otherwise leave the rest undefined. main() threads the same
115
+ // object into its onError, where a missing field throws out of the catch and
116
+ // the gate emits nothing, which the harness reads as a pass: fail OPEN.
117
+ const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
73
118
  const { Decision, EventKind } = controlPlane();
74
119
  // A payload the adapter cannot classify carries no readable prompt, so an
75
120
  // abstain would fail OPEN on harness contract drift; this gate's posture is
@@ -77,18 +122,24 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
77
122
  // channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
78
123
  // honors on UserPromptSubmit.)
79
124
  if (event.event === EventKind.UNKNOWN)
80
- return {
81
- decision: Decision.DENY,
82
- reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
83
- };
125
+ return { decision: Decision.DENY, reason: messages.unknownEvent };
84
126
  if (event.event !== EventKind.PROMPT_SUBMIT)
85
127
  return { decision: Decision.ALLOW };
86
- // The module-load guard: a missing stripper means agent-sanitizer never
87
- // loaded. Guarding on the stripper alone is sufficient — it loads AFTER
88
- // classifyPrompt in the same try, so a present stripper proves the classifier
89
- // loaded too.
128
+ // The module-load guard, one arm per binding. The two lazyImports are
129
+ // INDEPENDENT — each yields {} on its own failure — so a present stripper does
130
+ // not prove the classifier loaded, and guarding on it alone would let a
131
+ // classifier-only failure reach `classifyPrompt(...)` as a bare TypeError
132
+ // naming no package, no cause and no remedy: the exact diagnostic this hook
133
+ // now exists to produce. lazyImportErrorFor matches subpaths, so naming
134
+ // `agent-sanitizer/prompt` still recovers the recorded cause.
90
135
  if (typeof strip !== "function")
91
- throw new Error("agent-sanitizer is unavailable");
136
+ throw missingPackageError("agent-sanitizer", undefined, messages.remedy);
137
+ if (typeof classifyPrompt !== "function")
138
+ throw missingPackageError(
139
+ "agent-sanitizer/prompt",
140
+ undefined,
141
+ messages.remedy,
142
+ );
92
143
  // The contract guarantees a string here: every adapter normalizes the
93
144
  // prompt-submit input (Claude's parse coerces a missing/non-string prompt to
94
145
  // "" via asString), so a defensive typeof re-check is a dead branch.
@@ -97,13 +148,13 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
97
148
  const verdict = classifyPrompt(prompt, strip);
98
149
  if (verdict.action === "pass") return { decision: Decision.ALLOW };
99
150
  if (verdict.action === "note")
100
- return { decision: Decision.ALLOW, additional_context: SGR_NOTE };
151
+ return { decision: Decision.ALLOW, additional_context: messages.sgrNote };
101
152
  // block: carry the reason AND a context note — UserPromptSubmit can't rewrite
102
153
  // the prompt, so the context is the only forward signal about why it dropped.
103
154
  return {
104
155
  decision: Decision.DENY,
105
156
  reason: verdict.reason,
106
- additional_context: BLOCK_CONTEXT,
157
+ additional_context: messages.blockContext,
107
158
  };
108
159
  }
109
160
 
@@ -112,9 +163,19 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
112
163
  * @param {(chunk: string) => void} write
113
164
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
114
165
  * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
166
+ * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
167
+ * merged over the defaults so a partial table can never leave a field unset
115
168
  * @returns {Promise<void>}
116
169
  */
117
- export async function main(read, write, strip = stripAnsiFully) {
170
+ export async function main(
171
+ read,
172
+ write,
173
+ strip = stripAnsiFully,
174
+ overrides = USER_PROMPT_MESSAGES,
175
+ ) {
176
+ // Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
177
+ // call site where a missing field would throw out of the catch and fail OPEN.
178
+ const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
118
179
  // Delegate the parse → judge → render → write contract to the shared
119
180
  // runJudgeCli so this hook doesn't re-implement the control-plane boundary:
120
181
  // runJudgeCli reads stdin BEFORE loading the control-plane package, so a
@@ -125,7 +186,7 @@ export async function main(read, write, strip = stripAnsiFully) {
125
186
  await runJudgeCli(
126
187
  "sanitize-user-prompt",
127
188
  (event) => {
128
- const verdict = judgeSanitizeUserPrompt(event, strip);
189
+ const verdict = judgeSanitizeUserPrompt(event, strip, messages);
129
190
  // Announce engagement on the trace channel like the other stdin hooks —
130
191
  // a prompt gate that silently stopped running is otherwise invisible.
131
192
  trace(TraceEvent.HOOK_RAN, {
@@ -146,7 +207,7 @@ export async function main(read, write, strip = stripAnsiFully) {
146
207
  write(
147
208
  JSON.stringify({
148
209
  decision: "block",
149
- reason: `sanitize-user-prompt hook failed (fail-closed): ${safeErrMessage(err)}`,
210
+ reason: messages.hookFailed(safeErrMessage(err)),
150
211
  }),
151
212
  ),
152
213
  },
@@ -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.1",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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.";