agent-sanitizer 2.9.0 → 2.10.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.
package/README.md CHANGED
@@ -145,11 +145,12 @@ surface is the `--hook=` CLI, so these move between minor versions.
145
145
  `cliMain`, so a composer that wraps `cliMain` gets the hook's exact fail-closed
146
146
  CLI wiring plus its own policy:
147
147
 
148
- | Field | Runs | Does |
149
- | ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------ |
150
- | `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text |
151
- | `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning |
152
- | `audit` | once per judged event carrying a tool response | is handed the output the model will actually see |
148
+ | Field | Runs | Does |
149
+ | ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
150
+ | `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text |
151
+ | `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning |
152
+ | `audit` | once per judged event carrying a tool response | is handed the output the model will actually see, and the `session_id` it belongs to |
153
+ | `trace` | on every exit, in place of the package's trace channel | receives the engagement announcement (see the trace sink below) |
153
154
 
154
155
  Omit the bag and every seam is inert — the verdicts are byte-identical to this
155
156
  module alone. A callback that throws is **not** caught: it lands in the CLI's
@@ -172,6 +173,35 @@ secret-shaped output is suppressed, not shown unvetted. Layers 1–3 still run.
172
173
  never supply the `/output` seam's `filterInjection` callback, so nothing here
173
174
  calls a model or leaves the machine.
174
175
 
176
+ **Every hook's trace sink is injectable.** Each one announces that it engaged —
177
+ that is what makes a layer that stopped running loud rather than silent — and by
178
+ default that announcement goes to `_AGENT_SANITIZER_TRACE` /
179
+ `_AGENT_SANITIZER_TRACE_FILE`. A host that already runs a trace channel under
180
+ its own variables passes its own sink instead, so the announcement lands where
181
+ its detector actually reads:
182
+
183
+ ```js
184
+ import { cliMain } from "agent-sanitizer/claude-hooks/scan-invisible-chars";
185
+ await cliMain({ trace: (event, fields) => myChannel.emit(event, fields) });
186
+ ```
187
+
188
+ The sink rides each hook's options bag — `cliMain({trace})` on
189
+ `scan-invisible-chars` and `pretooluse-sanitize`, the extension bag's `trace` on
190
+ `sanitize-output`, `main(read, write, {trace})` on `sanitize-user-prompt`. It
191
+ receives the same `TraceEvent` names the default emits, and it **replaces** the
192
+ default rather than running alongside it — the package channel goes silent, so
193
+ there is one announcement to detect, not two. It may throw freely: each hook
194
+ binds the sink it is given through `bestEffortTrace`, so an announcement can
195
+ never be the thing that breaks a hook.
196
+
197
+ **A host's own cold-start marker can replace the derived one.** The hooks wait
198
+ out an in-flight dependency install by polling a marker file whose path they
199
+ derive from `CLAUDE_PROJECT_DIR`; a host whose setup script already writes one
200
+ calls `configureHookgateMarker(path)` (from `lib/hook-io`) before importing any
201
+ hook module, and every consumer waits on that path instead. `lib/control-plane`
202
+ resolves the marker at module scope, so a call that lands after that import
203
+ warns on stderr — it cannot steer the wait that already started.
204
+
175
205
  Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
176
206
  path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
177
207
  leading underscore marks them unstable — the supported surface is the `--hook=`
@@ -394,6 +394,43 @@ export function emitHookResponse(hookEventName, fields) {
394
394
  /** The marker filename stem; the project directory is appended to it. */
395
395
  const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
396
396
 
397
+ /**
398
+ * Host-supplied marker path, replacing the derived one. Null (the default) keeps
399
+ * the derivation below.
400
+ * @type {string | null}
401
+ */
402
+ let hookgateMarkerOverride = null;
403
+
404
+ /** Whether {@link hookgateMarkerPath} has already handed a path to a caller. */
405
+ let hookgateMarkerResolved = false;
406
+
407
+ /**
408
+ * Adopt a host's own cold-start marker path in place of the derived one, so a
409
+ * host whose setup script already writes a marker under its own convention can
410
+ * use these hooks without running a second, disagreeing wait loop against a path
411
+ * nothing writes.
412
+ *
413
+ * ORDERING, same rule as {@link registerLazyModules}: call this before importing
414
+ * any hook module. `lib/control-plane.mjs` resolves the marker at MODULE scope,
415
+ * so a call that lands after that import cannot reach the wait it was meant to
416
+ * steer. A late call is reported on stderr rather than thrown: a throw at a
417
+ * bundle entry's top level kills the hook process before it writes a response,
418
+ * and a hook that emits nothing is read as non-blocking — the fail-OPEN this
419
+ * whole file is built to avoid. The late call still takes effect for every
420
+ * later resolution.
421
+ * @param {string | null} path absolute marker path, or null to restore the derivation
422
+ * @returns {void}
423
+ */
424
+ export function configureHookgateMarker(path) {
425
+ if (hookgateMarkerResolved)
426
+ process.stderr.write(
427
+ "agent-sanitizer: configureHookgateMarker called after a marker path was " +
428
+ "already resolved; whatever resolved it is using the previous path and " +
429
+ "cannot be re-steered. Call it before importing any hook module.\n",
430
+ );
431
+ hookgateMarkerOverride = path;
432
+ }
433
+
397
434
  /**
398
435
  * Path of the cold-start in-flight marker a host's setup script writes
399
436
  * SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
@@ -405,7 +442,8 @@ const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
405
442
  * the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
406
443
  * canonicalization — the two must produce byte-identical paths), so no env has
407
444
  * to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
408
- * setup ran → nothing to wait on).
445
+ * setup ran → nothing to wait on), or whatever a host set via
446
+ * {@link configureHookgateMarker}.
409
447
  * @param {string | undefined} [projectDir]
410
448
  * @param {string | undefined} [runtimeDir]
411
449
  * @returns {string | null}
@@ -414,6 +452,8 @@ export function hookgateMarkerPath(
414
452
  projectDir = process.env.CLAUDE_PROJECT_DIR,
415
453
  runtimeDir = process.env.XDG_RUNTIME_DIR,
416
454
  ) {
455
+ hookgateMarkerResolved = true;
456
+ if (hookgateMarkerOverride !== null) return hookgateMarkerOverride;
417
457
  if (!projectDir) return null;
418
458
  // Prefer the per-user, mode-0700 runtime dir when the harness gives an
419
459
  // absolute one; else the world-writable /tmp, where markerIsTrusted() — not
@@ -9,10 +9,28 @@
9
9
  *
10
10
  * METADATA ONLY — never pass a tool_input body or secret material as a field; the
11
11
  * channel is not redaction-aware.
12
+ *
13
+ * The sink is INJECTABLE. A host that already runs a trace channel under its own
14
+ * environment variables — and a detector that reds when a defense layer stops
15
+ * announcing itself — passes its own {@link TraceFn} to each hook's entry point
16
+ * (`cliMain`, or `main` for the prompt gate) instead of forking this module. Left
17
+ * unsupplied, every hook uses {@link trace} below, so the shipped behavior is
18
+ * unchanged.
12
19
  */
13
20
 
14
21
  import { appendFileSync } from "node:fs";
15
22
 
23
+ /**
24
+ * The sink shape a hook emits through: the event name, its metadata fields, and
25
+ * the level. A host implementation receives the same {@link TraceEvent} names the
26
+ * default emits, so it can remap them onto its own channel's vocabulary.
27
+ *
28
+ * A sink is NOT required to be total — throw freely. Every hook binds the one it
29
+ * was given through {@link bestEffortTrace}, which is what upholds the channel's
30
+ * never-breaks-a-hook posture on host code that cannot promise it.
31
+ * @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
32
+ */
33
+
16
34
  /** Trace-channel event names. */
17
35
  export const TraceEvent = Object.freeze({
18
36
  HOOK_RAN: "hook_ran",
@@ -58,3 +76,31 @@ export function trace(event, fields = {}, level = "info") {
58
76
  // best-effort: a trace we can't write must never break a hook.
59
77
  }
60
78
  }
79
+
80
+ /**
81
+ * `sink` with {@link trace}'s best-effort posture forced onto it: a throw is
82
+ * swallowed, so an announcement can never break the hook making it.
83
+ *
84
+ * This is what makes the sink safely injectable. The announcement call sites were
85
+ * placed under the guarantee that emitting cannot fail, and one of them relies on
86
+ * it outright: scan-invisible-chars announces BEFORE it auto-cleans the
87
+ * contaminated instruction files and arms the PreToolUse gate, with no catch
88
+ * above it, so a throwing sink there would abort the scan — leaving the payload on
89
+ * disk, the gate un-armed, and NO announcement on any channel. The loss the
90
+ * announcement exists to make loud would itself be silent.
91
+ *
92
+ * Swallowing is right here and is not licence to swallow elsewhere in this tree:
93
+ * a dropped announcement is already loud in the host's own detector — that is what
94
+ * a trace channel is — whereas a killed hook is loud nowhere.
95
+ * @param {TraceFn} sink
96
+ * @returns {TraceFn}
97
+ */
98
+ export function bestEffortTrace(sink) {
99
+ return (event, fields, level) => {
100
+ try {
101
+ sink(event, fields, level);
102
+ } catch {
103
+ // See above: an announcement must never be the thing that breaks a hook.
104
+ }
105
+ };
106
+ }
@@ -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
  };
@@ -50,7 +50,7 @@ import {
50
50
  authoredContext,
51
51
  } from "./lib/authored-content.mjs";
52
52
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
53
- import { trace, TraceEvent } from "./lib/trace.mjs";
53
+ import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
54
54
 
55
55
  const HOOK_NAME = "pretooluse-sanitize";
56
56
 
@@ -72,6 +72,7 @@ const HOOK_NAME = "pretooluse-sanitize";
72
72
  * unknownEvent: string,
73
73
  * failed: (cause: string) => string,
74
74
  * unparsable: (cause: string) => string,
75
+ * remedy: string,
75
76
  * }>}
76
77
  */
77
78
  export const PRE_TOOL_USE_MESSAGES = Object.freeze({
@@ -79,6 +80,11 @@ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
79
80
  "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
80
81
  failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
81
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,
82
88
  });
83
89
 
84
90
  // Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
@@ -144,18 +150,19 @@ const defaultRehydrate = (tool, toolInput) =>
144
150
  * it unchanged. The trace lives on this in-process, mutation-tested path, not in
145
151
  * the CLI block, so engagement is announced (hook_ran — metadata only: hook
146
152
  * name, tool, outcome) for every exit.
153
+ * @param {import("./lib/trace.mjs").TraceFn} emitTrace
147
154
  * @param {string} toolName
148
155
  * @param {Record<string, unknown> | null} fields
149
156
  * @returns {Record<string, unknown> | null}
150
157
  */
151
- function emitTraced(toolName, fields) {
158
+ function emitTraced(emitTrace, toolName, fields) {
152
159
  let outcome = "modified";
153
160
  if (fields === null) outcome = "noop";
154
161
  else if (fields.permissionDecision === PermissionDecision.DENY)
155
162
  outcome = "deny";
156
163
  else if (fields.permissionDecision === PermissionDecision.ASK)
157
164
  outcome = "ask";
158
- trace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
165
+ emitTrace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
159
166
  return fields;
160
167
  }
161
168
 
@@ -167,12 +174,18 @@ function emitTraced(toolName, fields) {
167
174
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
168
175
  * injectable for tests; the default binds the real redactor-daemon io (the
169
176
  * layer reads the target file and maps secrets through the daemon)
177
+ * @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
178
+ * announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
170
179
  * @returns {Promise<Record<string, unknown> | null>}
171
180
  */
172
181
  export async function buildPreToolUseResponse(
173
182
  input,
174
183
  rehydrate = defaultRehydrate,
184
+ sink = trace,
175
185
  ) {
186
+ // Every path into the announcement runs through here, so this is the one place
187
+ // a host sink has to be made best-effort (see bestEffortTrace).
188
+ const emitTrace = bestEffortTrace(sink);
176
189
  const asks = [];
177
190
  const contexts = [];
178
191
 
@@ -221,7 +234,7 @@ export async function buildPreToolUseResponse(
221
234
  // above, so it returns immediately.
222
235
  const rehydrated = await rehydrate(tool, current);
223
236
  if (rehydrated && "deny" in rehydrated)
224
- return emitTraced(input.tool_name, {
237
+ return emitTraced(emitTrace, input.tool_name, {
225
238
  permissionDecision: PermissionDecision.DENY,
226
239
  permissionDecisionReason: rehydrated.deny,
227
240
  });
@@ -232,6 +245,7 @@ export async function buildPreToolUseResponse(
232
245
  }
233
246
 
234
247
  return emitTraced(
248
+ emitTrace,
235
249
  input.tool_name,
236
250
  assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
237
251
  );
@@ -284,12 +298,16 @@ function assembleResponse({
284
298
  * fail-closed posture holds even when the adapter never loaded.
285
299
  * @param {import("agent-control-plane-core").ToolCallEvent} event
286
300
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
287
- * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
301
+ * @param {{
302
+ * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
303
+ * gates?: HostGate[],
304
+ * trace?: import("./lib/trace.mjs").TraceFn,
305
+ * }} [opts]
288
306
  * messages are merged over the defaults, so a partial table is supported
289
307
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
290
308
  */
291
309
  export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
292
- const { gates = [] } = opts;
310
+ const { gates = [], trace: emitTrace = trace } = opts;
293
311
  // MERGED over the defaults, never substituted for them. A host that overrides
294
312
  // one field would otherwise leave the rest undefined, and the miss lands in
295
313
  // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
@@ -321,7 +339,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
321
339
  const denyReason = gate(input);
322
340
  if (denyReason) return { decision: Decision.DENY, reason: denyReason };
323
341
  }
324
- const fields = await buildPreToolUseResponse(input, rehydrate);
342
+ const fields = await buildPreToolUseResponse(input, rehydrate, emitTrace);
325
343
  if (fields === null) return { decision: Decision.ALLOW };
326
344
  /** @type {Record<string, unknown>} */
327
345
  const verdict = {
@@ -413,16 +431,21 @@ export function failClosedFields(parsedOk, err, opts = {}) {
413
431
  * @param {{
414
432
  * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
415
433
  * gates?: HostGate[],
416
- * remedy?: string,
434
+ * trace?: import("./lib/trace.mjs").TraceFn,
417
435
  * }} [opts]
418
436
  * @returns {Promise<void>}
419
437
  */
420
438
  export async function cliMain(opts = {}) {
421
- const { gates = [], remedy } = opts;
439
+ const { gates = [], trace: emitTrace = trace } = opts;
422
440
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
423
441
  await runJudgeCli(
424
442
  HOOK_NAME,
425
- (event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
443
+ (event) =>
444
+ judgePreToolUseSanitize(event, undefined, {
445
+ messages,
446
+ gates,
447
+ trace: emitTrace,
448
+ }),
426
449
  {
427
450
  // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
428
451
  // hard-denies (adversary-inducible, no benefit to failing); any throw
@@ -434,7 +457,7 @@ export async function cliMain(opts = {}) {
434
457
  HookEvent.PRE_TOOL_USE,
435
458
  failClosedFields(input !== undefined, err, {
436
459
  messages,
437
- hint: depLoadHint(err, remedy),
460
+ hint: depLoadHint(err, messages.remedy),
438
461
  }),
439
462
  ),
440
463
  },
@@ -34,7 +34,7 @@ import {
34
34
  HookEvent,
35
35
  } from "./lib/hook-io.mjs";
36
36
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
37
- import { trace, TraceEvent } from "./lib/trace.mjs";
37
+ import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
38
38
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
39
39
  import {
40
40
  persistReveal,
@@ -192,9 +192,22 @@ async function redactSecrets(text, webIngress = false, deadline) {
192
192
  * @property {(raw: string) => string | undefined} [redactNote]
193
193
  * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
194
194
  * appended to that leaf's "API keys/secrets redacted: …" warning.
195
- * @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
195
+ * @property {(record: { tool: string | null, session_id?: string, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
196
196
  * Awaited once per judged event that carried a tool response, with the output
197
- * the model will actually see.
197
+ * the model will actually see. `session_id` is the harness's session identity,
198
+ * lifted from the event's `meta` — an audit trail that cannot say WHICH session
199
+ * produced a record cannot be read back per-session, and the tool fields alone
200
+ * do not carry it. Absent when the payload omitted it.
201
+ * @property {import("./lib/trace.mjs").TraceFn} [trace]
202
+ * Where this hook announces engagement. A host that already runs a trace
203
+ * channel under its own environment variables passes its sink here, so the
204
+ * announcement lands where its detector reads instead of on this package's
205
+ * channel. Defaults to lib/trace.mjs's `trace`.
206
+ * @property {string} [remedy]
207
+ * What a reader should run when the sanitizer's own bindings are what is
208
+ * missing. This hook's host channel is `ext`, where the other two gates use a
209
+ * frozen message table; either way it is one channel per gate, so a host
210
+ * cannot supply its wording somewhere the fail-closed context never reads.
198
211
  */
199
212
 
200
213
  /**
@@ -528,14 +541,19 @@ export function failClosedContext(
528
541
  * @param {any} input parsed hook input, or undefined if parsing threw
529
542
  * @param {string} message
530
543
  * @param {(fields: Record<string, unknown>) => void} [emit]
544
+ * @param {string} [remedy] what a reader should run; hosts pass their own
531
545
  * @returns {void}
532
546
  */
533
547
  export function emitFailClosed(
534
548
  input,
535
549
  message,
536
550
  emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
551
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
537
552
  ) {
538
- const additionalContext = failClosedContext();
553
+ // Threaded rather than defaulted here: this is the ONLY production caller of
554
+ // failClosedContext, so a remedy it does not pass is a remedy no host can ever
555
+ // reach — the parameter would be live only from tests.
556
+ const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
539
557
  try {
540
558
  emit({
541
559
  updatedToolOutput: failClosedReplacement(input, message),
@@ -562,13 +580,16 @@ export function emitFailClosed(
562
580
  * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
563
581
  */
564
582
  export async function evaluateToolOutput(input, ext = {}) {
583
+ // Best-effort, like the default sink: a host callback that throws must not be
584
+ // the thing that suppresses a tool output (see bestEffortTrace).
585
+ const emitTrace = bestEffortTrace(ext.trace ?? trace);
565
586
  /**
566
587
  * @param {string} outcome noop | clean | flagged | modified
567
588
  * @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
568
589
  * @returns {{ mutated_output?: unknown, additional_context?: string } | null}
569
590
  */
570
591
  const emit = (outcome, fields) => {
571
- trace(TraceEvent.HOOK_RAN, {
592
+ emitTrace(TraceEvent.HOOK_RAN, {
572
593
  hook: HOOK_NAME,
573
594
  tool: input.tool_name,
574
595
  outcome,
@@ -706,6 +727,10 @@ export async function judgeSanitizeOutput(event, ext = {}) {
706
727
  const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
707
728
  await ext.audit({
708
729
  tool: event.tool,
730
+ // The session identity travels in `meta`, not alongside the tool fields, so
731
+ // a recorder filing one trail per session cannot reach it unless it is
732
+ // lifted here.
733
+ session_id: event.meta?.session_id,
709
734
  modified,
710
735
  output: modified ? fields?.mutated_output : event.response,
711
736
  context: fields?.additional_context,
@@ -774,6 +799,8 @@ export async function cliMain(ext = {}) {
774
799
  "[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
775
800
  safeErrMessage(err) +
776
801
  "]",
802
+ undefined,
803
+ ext.remedy,
777
804
  ),
778
805
  },
779
806
  );
@@ -22,17 +22,20 @@ import {
22
22
  readStdinJson,
23
23
  safeErrMessage,
24
24
  isMain,
25
+ lazyImport,
25
26
  missingPackageError,
27
+ DEFAULT_MISSING_PACKAGE_REMEDY,
26
28
  } from "./lib/hook-io.mjs";
27
29
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
28
- import { trace, TraceEvent } from "./lib/trace.mjs";
30
+ import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
29
31
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
30
32
  // come from the agent-sanitizer package. They are bound by a *caught* dynamic
31
33
  // import, never a bare top-level `import … from "…"`: a static npm import
32
34
  // resolves before any try/catch, so a missing node_modules would crash this hook
33
35
  // at load and let the prompt through UNSANITIZED (fail-open). A failed load
34
- // leaves the bindings undefined, which main()'s typeof guard turns into a
35
- // 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.
36
39
  /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
37
40
  export let classifyPrompt;
38
41
  /** @type {typeof import("agent-sanitizer").stripAnsiFully} */
@@ -49,6 +52,7 @@ let stripAnsiFully;
49
52
  * blockContext: string,
50
53
  * sgrNote: string,
51
54
  * hookFailed: (cause: string) => string,
55
+ * remedy: string,
52
56
  * }>}
53
57
  */
54
58
  export const USER_PROMPT_MESSAGES = Object.freeze({
@@ -59,24 +63,31 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
59
63
  "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
60
64
  hookFailed: (cause) =>
61
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,
62
71
  });
63
72
 
64
73
  /* c8 ignore start — module-load boundary: the imports resolve in every real
65
74
  * run, and their failure (the package absent) can't be simulated in-process, so
66
- * 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
67
76
  * converts an undefined stripper into a fail-closed block — that guard IS tested. */
68
77
  // Stryker disable all
69
- try {
70
- // The /prompt subpath is imported first: if it fails, the catch fires before
71
- // stripAnsiFully is assigned, so a half-load can never leave the stripper set
72
- // while the classifier is missing (main guards on the stripper alone).
73
- ({ classifyPrompt } = await import("agent-sanitizer/prompt"));
74
- ({ stripAnsiFully } = await import("agent-sanitizer"));
75
- } catch {
76
- // Leave classifyPrompt/stripAnsiFully undefined so main()'s typeof guard fails
77
- // closed — the prompt is blocked, never passed through with the package
78
- // half-loaded.
79
- }
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
+ ));
80
91
  // Stryker restore all
81
92
  /* c8 ignore stop */
82
93
 
@@ -114,11 +125,21 @@ export function judgeSanitizeUserPrompt(
114
125
  return { decision: Decision.DENY, reason: messages.unknownEvent };
115
126
  if (event.event !== EventKind.PROMPT_SUBMIT)
116
127
  return { decision: Decision.ALLOW };
117
- // The module-load guard: a missing stripper means agent-sanitizer never
118
- // loaded. Guarding on the stripper alone is sufficientit loads AFTER
119
- // classifyPrompt in the same try, so a present stripper proves the classifier
120
- // loaded too.
121
- if (typeof strip !== "function") throw missingPackageError("agent-sanitizer");
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.
135
+ if (typeof strip !== "function")
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
+ );
122
143
  // The contract guarantees a string here: every adapter normalizes the
123
144
  // prompt-submit input (Claude's parse coerces a missing/non-string prompt to
124
145
  // "" via asString), so a defensive typeof re-check is a dead branch.
@@ -138,20 +159,30 @@ export function judgeSanitizeUserPrompt(
138
159
  }
139
160
 
140
161
  /**
162
+ * `read` and `write` stay positional — every caller supplies both — while the
163
+ * injectable seams ride in one bag, so a host supplying only the last of them does
164
+ * not have to pass `undefined` for the others.
141
165
  * @param {() => Promise<any> | any} read
142
166
  * @param {(chunk: string) => void} write
143
- * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
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
167
+ * @param {{
168
+ * strip?: ((s: string) => string) | null,
169
+ * overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
170
+ * trace?: import("./lib/trace.mjs").TraceFn,
171
+ * }} [opts]
172
+ * `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
173
+ * injectable so the fail-closed path is testable); `overrides` are reason
174
+ * overrides, merged over the defaults so a partial table can never leave a field
175
+ * unset; `trace` is where engagement is announced, for a host with its own trace
176
+ * channel (see lib/trace.mjs).
147
177
  * @returns {Promise<void>}
148
178
  */
149
- export async function main(
150
- read,
151
- write,
152
- strip = stripAnsiFully,
153
- overrides = USER_PROMPT_MESSAGES,
154
- ) {
179
+ export async function main(read, write, opts = {}) {
180
+ const {
181
+ strip = stripAnsiFully,
182
+ overrides = USER_PROMPT_MESSAGES,
183
+ trace: sink = trace,
184
+ } = opts;
185
+ const emitTrace = bestEffortTrace(sink);
155
186
  // Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
156
187
  // call site where a missing field would throw out of the catch and fail OPEN.
157
188
  const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
@@ -168,7 +199,7 @@ export async function main(
168
199
  const verdict = judgeSanitizeUserPrompt(event, strip, messages);
169
200
  // Announce engagement on the trace channel like the other stdin hooks —
170
201
  // a prompt gate that silently stopped running is otherwise invisible.
171
- trace(TraceEvent.HOOK_RAN, {
202
+ emitTrace(TraceEvent.HOOK_RAN, {
172
203
  hook: "sanitize-user-prompt",
173
204
  outcome:
174
205
  verdict.decision === controlPlane().Decision.DENY
@@ -21,7 +21,7 @@ import {
21
21
  ALERT_ACK_FILE,
22
22
  PROJECT_DIR,
23
23
  } from "./lib/invisible-alert.mjs";
24
- import { trace, TraceEvent } from "./lib/trace.mjs";
24
+ import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
25
25
 
26
26
  // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
27
27
  // hazard of a bare static npm import — here the instruction files would load
@@ -271,9 +271,16 @@ function scanProject() {
271
271
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
272
272
  * (which must claim the CLI slot before this module loads) can run the exact
273
273
  * same scan instead of duplicating it.
274
+ * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
275
+ * this scan announces engagement; a host with its own trace channel passes its
276
+ * sink so the announcement lands where its detector reads (see lib/trace.mjs).
274
277
  * @returns {Promise<void>}
275
278
  */
276
- export async function cliMain() {
279
+ export async function cliMain({ trace: sink = trace } = {}) {
280
+ // Bound best-effort: the announcements below run BEFORE the auto-clean and
281
+ // the alert write, with no catch above them, so a throwing host sink would
282
+ // abort the scan silently (see bestEffortTrace).
283
+ const emitTrace = bestEffortTrace(sink);
277
284
  /* c8 ignore start -- fail-closed module-load guard: only reachable when the
278
285
  agent-sanitizer import above failed, which can't be simulated in the
279
286
  spawned-subprocess CLI run the tests observe. */
@@ -281,7 +288,7 @@ export async function cliMain() {
281
288
  // Emit the engagement event with a "skipped" outcome so the loss is LOUD on
282
289
  // the trace channel — a scan that never ran is otherwise invisible, and the
283
290
  // downstream PreToolUse sanitize gate then passes cleanly all session.
284
- trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
291
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
285
292
  process.stderr.write(
286
293
  "scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
287
294
  "installed and session-setup did not finish in time); instruction " +
@@ -304,10 +311,10 @@ export async function cliMain() {
304
311
  const allFindings = scanProject();
305
312
 
306
313
  if (allFindings.length === 0) {
307
- trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
314
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
308
315
  return;
309
316
  }
310
- trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
317
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
311
318
  outcome: "found",
312
319
  files: allFindings.length,
313
320
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.9.0",
3
+ "version": "2.10.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": {
@@ -167,6 +167,24 @@ export function safeErrMessage(err: unknown, cap?: number): string;
167
167
  * @returns {void}
168
168
  */
169
169
  export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
170
+ /**
171
+ * Adopt a host's own cold-start marker path in place of the derived one, so a
172
+ * host whose setup script already writes a marker under its own convention can
173
+ * use these hooks without running a second, disagreeing wait loop against a path
174
+ * nothing writes.
175
+ *
176
+ * ORDERING, same rule as {@link registerLazyModules}: call this before importing
177
+ * any hook module. `lib/control-plane.mjs` resolves the marker at MODULE scope,
178
+ * so a call that lands after that import cannot reach the wait it was meant to
179
+ * steer. A late call is reported on stderr rather than thrown: a throw at a
180
+ * bundle entry's top level kills the hook process before it writes a response,
181
+ * and a hook that emits nothing is read as non-blocking — the fail-OPEN this
182
+ * whole file is built to avoid. The late call still takes effect for every
183
+ * later resolution.
184
+ * @param {string | null} path absolute marker path, or null to restore the derivation
185
+ * @returns {void}
186
+ */
187
+ export function configureHookgateMarker(path: string | null): void;
170
188
  /**
171
189
  * Path of the cold-start in-flight marker a host's setup script writes
172
190
  * SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
@@ -178,7 +196,8 @@ export function emitHookResponse(hookEventName: string, fields: Record<string, u
178
196
  * the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
179
197
  * canonicalization — the two must produce byte-identical paths), so no env has
180
198
  * to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
181
- * setup ran → nothing to wait on).
199
+ * setup ran → nothing to wait on), or whatever a host set via
200
+ * {@link configureHookgateMarker}.
182
201
  * @param {string | undefined} [projectDir]
183
202
  * @param {string | undefined} [runtimeDir]
184
203
  * @returns {string | null}
@@ -14,8 +14,47 @@ export function traceThreshold(env?: NodeJS.ProcessEnv): number;
14
14
  * @returns {void}
15
15
  */
16
16
  export function trace(event: string, fields?: Record<string, unknown>, level?: "info" | "debug"): void;
17
+ /**
18
+ * `sink` with {@link trace}'s best-effort posture forced onto it: a throw is
19
+ * swallowed, so an announcement can never break the hook making it.
20
+ *
21
+ * This is what makes the sink safely injectable. The announcement call sites were
22
+ * placed under the guarantee that emitting cannot fail, and one of them relies on
23
+ * it outright: scan-invisible-chars announces BEFORE it auto-cleans the
24
+ * contaminated instruction files and arms the PreToolUse gate, with no catch
25
+ * above it, so a throwing sink there would abort the scan — leaving the payload on
26
+ * disk, the gate un-armed, and NO announcement on any channel. The loss the
27
+ * announcement exists to make loud would itself be silent.
28
+ *
29
+ * Swallowing is right here and is not licence to swallow elsewhere in this tree:
30
+ * a dropped announcement is already loud in the host's own detector — that is what
31
+ * a trace channel is — whereas a killed hook is loud nowhere.
32
+ * @param {TraceFn} sink
33
+ * @returns {TraceFn}
34
+ */
35
+ export function bestEffortTrace(sink: TraceFn): TraceFn;
36
+ /**
37
+ * The sink shape a hook emits through: the event name, its metadata fields, and
38
+ * the level. A host implementation receives the same {@link TraceEvent} names the
39
+ * default emits, so it can remap them onto its own channel's vocabulary.
40
+ *
41
+ * A sink is NOT required to be total — throw freely. Every hook binds the one it
42
+ * was given through {@link bestEffortTrace}, which is what upholds the channel's
43
+ * never-breaks-a-hook posture on host code that cannot promise it.
44
+ * @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
45
+ */
17
46
  /** Trace-channel event names. */
18
47
  export const TraceEvent: Readonly<{
19
48
  HOOK_RAN: "hook_ran";
20
49
  SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran";
21
50
  }>;
51
+ /**
52
+ * The sink shape a hook emits through: the event name, its metadata fields, and
53
+ * the level. A host implementation receives the same {@link TraceEvent} names the
54
+ * default emits, so it can remap them onto its own channel's vocabulary.
55
+ *
56
+ * A sink is NOT required to be total — throw freely. Every hook binds the one it
57
+ * was given through {@link bestEffortTrace}, which is what upholds the channel's
58
+ * never-breaks-a-hook posture on host code that cannot promise it.
59
+ */
60
+ export type TraceFn = (event: string, fields?: Record<string, unknown>, level?: "info" | "debug") => void;
@@ -6,9 +6,11 @@
6
6
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
7
7
  * injectable for tests; the default binds the real redactor-daemon io (the
8
8
  * layer reads the target file and maps secrets through the daemon)
9
+ * @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
10
+ * announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
9
11
  * @returns {Promise<Record<string, unknown> | null>}
10
12
  */
11
- export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>): Promise<Record<string, unknown> | null>;
13
+ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, sink?: import("./lib/trace.mjs").TraceFn): Promise<Record<string, unknown> | null>;
12
14
  /**
13
15
  * Agent-agnostic judge over the four protections: consumes a control-plane
14
16
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
@@ -19,13 +21,18 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
19
21
  * fail-closed posture holds even when the adapter never loaded.
20
22
  * @param {import("agent-control-plane-core").ToolCallEvent} event
21
23
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
22
- * @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
24
+ * @param {{
25
+ * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
26
+ * gates?: HostGate[],
27
+ * trace?: import("./lib/trace.mjs").TraceFn,
28
+ * }} [opts]
23
29
  * messages are merged over the defaults, so a partial table is supported
24
30
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
25
31
  */
26
32
  export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, opts?: {
27
33
  messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
28
34
  gates?: HostGate[];
35
+ trace?: import("./lib/trace.mjs").TraceFn;
29
36
  }): Promise<import("agent-control-plane-core").Verdict>;
30
37
  /**
31
38
  * The dependency-load failure hiding behind a hook error, or "". A binding that
@@ -67,14 +74,14 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
67
74
  * @param {{
68
75
  * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
69
76
  * gates?: HostGate[],
70
- * remedy?: string,
77
+ * trace?: import("./lib/trace.mjs").TraceFn,
71
78
  * }} [opts]
72
79
  * @returns {Promise<void>}
73
80
  */
74
81
  export function cliMain(opts?: {
75
82
  messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
76
83
  gates?: HostGate[];
77
- remedy?: string;
84
+ trace?: import("./lib/trace.mjs").TraceFn;
78
85
  }): Promise<void>;
79
86
  /**
80
87
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
@@ -93,12 +100,14 @@ export function cliMain(opts?: {
93
100
  * unknownEvent: string,
94
101
  * failed: (cause: string) => string,
95
102
  * unparsable: (cause: string) => string,
103
+ * remedy: string,
96
104
  * }>}
97
105
  */
98
106
  export const PRE_TOOL_USE_MESSAGES: Readonly<{
99
107
  unknownEvent: string;
100
108
  failed: (cause: string) => string;
101
109
  unparsable: (cause: string) => string;
110
+ remedy: string;
102
111
  }>;
103
112
  /**
104
113
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
@@ -27,9 +27,22 @@
27
27
  * @property {(raw: string) => string | undefined} [redactNote]
28
28
  * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
29
29
  * appended to that leaf's "API keys/secrets redacted: …" warning.
30
- * @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
30
+ * @property {(record: { tool: string | null, session_id?: string, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
31
31
  * Awaited once per judged event that carried a tool response, with the output
32
- * the model will actually see.
32
+ * the model will actually see. `session_id` is the harness's session identity,
33
+ * lifted from the event's `meta` — an audit trail that cannot say WHICH session
34
+ * produced a record cannot be read back per-session, and the tool fields alone
35
+ * do not carry it. Absent when the payload omitted it.
36
+ * @property {import("./lib/trace.mjs").TraceFn} [trace]
37
+ * Where this hook announces engagement. A host that already runs a trace
38
+ * channel under its own environment variables passes its sink here, so the
39
+ * announcement lands where its detector reads instead of on this package's
40
+ * channel. Defaults to lib/trace.mjs's `trace`.
41
+ * @property {string} [remedy]
42
+ * What a reader should run when the sanitizer's own bindings are what is
43
+ * missing. This hook's host channel is `ext`, where the other two gates use a
44
+ * frozen message table; either way it is one channel per gate, so a host
45
+ * cannot supply its wording somewhere the fail-closed context never reads.
33
46
  */
34
47
  /**
35
48
  * Run Layers 1-4 over a single text blob, delegated to the package's output seam
@@ -142,9 +155,10 @@ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string):
142
155
  * @param {any} input parsed hook input, or undefined if parsing threw
143
156
  * @param {string} message
144
157
  * @param {(fields: Record<string, unknown>) => void} [emit]
158
+ * @param {string} [remedy] what a reader should run; hosts pass their own
145
159
  * @returns {void}
146
160
  */
147
- export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void): void;
161
+ export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void, remedy?: string): void;
148
162
  /**
149
163
  * Run the sanitization pipeline over a tool output and return the contract-
150
164
  * shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
@@ -258,12 +272,30 @@ export type SanitizeExtensions = {
258
272
  redactNote?: ((raw: string) => string | undefined) | undefined;
259
273
  /**
260
274
  * Awaited once per judged event that carried a tool response, with the output
261
- * the model will actually see.
275
+ * the model will actually see. `session_id` is the harness's session identity,
276
+ * lifted from the event's `meta` — an audit trail that cannot say WHICH session
277
+ * produced a record cannot be read back per-session, and the tool fields alone
278
+ * do not carry it. Absent when the payload omitted it.
262
279
  */
263
280
  audit?: ((record: {
264
281
  tool: string | null;
282
+ session_id?: string;
265
283
  modified: boolean;
266
284
  output: unknown;
267
285
  context?: string;
268
286
  }) => Promise<void> | void) | undefined;
287
+ /**
288
+ * Where this hook announces engagement. A host that already runs a trace
289
+ * channel under its own environment variables passes its sink here, so the
290
+ * announcement lands where its detector reads instead of on this package's
291
+ * channel. Defaults to lib/trace.mjs's `trace`.
292
+ */
293
+ trace?: import("./lib/trace.mjs").TraceFn | undefined;
294
+ /**
295
+ * What a reader should run when the sanitizer's own bindings are what is
296
+ * missing. This hook's host channel is `ext`, where the other two gates use a
297
+ * frozen message table; either way it is one channel per gate, so a host
298
+ * cannot supply its wording somewhere the fail-closed context never reads.
299
+ */
300
+ remedy?: string | undefined;
269
301
  };
@@ -14,15 +14,28 @@
14
14
  */
15
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;
16
16
  /**
17
+ * `read` and `write` stay positional — every caller supplies both — while the
18
+ * injectable seams ride in one bag, so a host supplying only the last of them does
19
+ * not have to pass `undefined` for the others.
17
20
  * @param {() => Promise<any> | any} read
18
21
  * @param {(chunk: string) => void} write
19
- * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
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
22
+ * @param {{
23
+ * strip?: ((s: string) => string) | null,
24
+ * overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
25
+ * trace?: import("./lib/trace.mjs").TraceFn,
26
+ * }} [opts]
27
+ * `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
28
+ * injectable so the fail-closed path is testable); `overrides` are reason
29
+ * overrides, merged over the defaults so a partial table can never leave a field
30
+ * unset; `trace` is where engagement is announced, for a host with its own trace
31
+ * channel (see lib/trace.mjs).
23
32
  * @returns {Promise<void>}
24
33
  */
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>;
34
+ export function main(read: () => Promise<any> | any, write: (chunk: string) => void, opts?: {
35
+ strip?: ((s: string) => string) | null;
36
+ overrides?: Partial<typeof USER_PROMPT_MESSAGES>;
37
+ trace?: import("./lib/trace.mjs").TraceFn;
38
+ }): Promise<void>;
26
39
  /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
27
40
  export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
28
41
  /**
@@ -36,6 +49,7 @@ export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPromp
36
49
  * blockContext: string,
37
50
  * sgrNote: string,
38
51
  * hookFailed: (cause: string) => string,
52
+ * remedy: string,
39
53
  * }>}
40
54
  */
41
55
  export const USER_PROMPT_MESSAGES: Readonly<{
@@ -43,4 +57,5 @@ export const USER_PROMPT_MESSAGES: Readonly<{
43
57
  blockContext: string;
44
58
  sgrNote: string;
45
59
  hookFailed: (cause: string) => string;
60
+ remedy: string;
46
61
  }>;
@@ -3,9 +3,14 @@
3
3
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
4
4
  * (which must claim the CLI slot before this module loads) can run the exact
5
5
  * same scan instead of duplicating it.
6
+ * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
7
+ * this scan announces engagement; a host with its own trace channel passes its
8
+ * sink so the announcement lands where its detector reads (see lib/trace.mjs).
6
9
  * @returns {Promise<void>}
7
10
  */
8
- export function cliMain(): Promise<void>;
11
+ export function cliMain({ trace: sink }?: {
12
+ trace?: import("./lib/trace.mjs").TraceFn;
13
+ }): Promise<void>;
9
14
  /**
10
15
  * @param {string} run
11
16
  * @returns {{ method: string, decoded: string }}