agent-sanitizer 2.9.1 → 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
+ }
@@ -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
 
@@ -150,18 +150,19 @@ const defaultRehydrate = (tool, toolInput) =>
150
150
  * it unchanged. The trace lives on this in-process, mutation-tested path, not in
151
151
  * the CLI block, so engagement is announced (hook_ran — metadata only: hook
152
152
  * name, tool, outcome) for every exit.
153
+ * @param {import("./lib/trace.mjs").TraceFn} emitTrace
153
154
  * @param {string} toolName
154
155
  * @param {Record<string, unknown> | null} fields
155
156
  * @returns {Record<string, unknown> | null}
156
157
  */
157
- function emitTraced(toolName, fields) {
158
+ function emitTraced(emitTrace, toolName, fields) {
158
159
  let outcome = "modified";
159
160
  if (fields === null) outcome = "noop";
160
161
  else if (fields.permissionDecision === PermissionDecision.DENY)
161
162
  outcome = "deny";
162
163
  else if (fields.permissionDecision === PermissionDecision.ASK)
163
164
  outcome = "ask";
164
- trace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
165
+ emitTrace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
165
166
  return fields;
166
167
  }
167
168
 
@@ -173,12 +174,18 @@ function emitTraced(toolName, fields) {
173
174
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
174
175
  * injectable for tests; the default binds the real redactor-daemon io (the
175
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)
176
179
  * @returns {Promise<Record<string, unknown> | null>}
177
180
  */
178
181
  export async function buildPreToolUseResponse(
179
182
  input,
180
183
  rehydrate = defaultRehydrate,
184
+ sink = trace,
181
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);
182
189
  const asks = [];
183
190
  const contexts = [];
184
191
 
@@ -227,7 +234,7 @@ export async function buildPreToolUseResponse(
227
234
  // above, so it returns immediately.
228
235
  const rehydrated = await rehydrate(tool, current);
229
236
  if (rehydrated && "deny" in rehydrated)
230
- return emitTraced(input.tool_name, {
237
+ return emitTraced(emitTrace, input.tool_name, {
231
238
  permissionDecision: PermissionDecision.DENY,
232
239
  permissionDecisionReason: rehydrated.deny,
233
240
  });
@@ -238,6 +245,7 @@ export async function buildPreToolUseResponse(
238
245
  }
239
246
 
240
247
  return emitTraced(
248
+ emitTrace,
241
249
  input.tool_name,
242
250
  assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
243
251
  );
@@ -290,12 +298,16 @@ function assembleResponse({
290
298
  * fail-closed posture holds even when the adapter never loaded.
291
299
  * @param {import("agent-control-plane-core").ToolCallEvent} event
292
300
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
293
- * @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]
294
306
  * messages are merged over the defaults, so a partial table is supported
295
307
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
296
308
  */
297
309
  export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
298
- const { gates = [] } = opts;
310
+ const { gates = [], trace: emitTrace = trace } = opts;
299
311
  // MERGED over the defaults, never substituted for them. A host that overrides
300
312
  // one field would otherwise leave the rest undefined, and the miss lands in
301
313
  // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
@@ -327,7 +339,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
327
339
  const denyReason = gate(input);
328
340
  if (denyReason) return { decision: Decision.DENY, reason: denyReason };
329
341
  }
330
- const fields = await buildPreToolUseResponse(input, rehydrate);
342
+ const fields = await buildPreToolUseResponse(input, rehydrate, emitTrace);
331
343
  if (fields === null) return { decision: Decision.ALLOW };
332
344
  /** @type {Record<string, unknown>} */
333
345
  const verdict = {
@@ -419,15 +431,21 @@ export function failClosedFields(parsedOk, err, opts = {}) {
419
431
  * @param {{
420
432
  * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
421
433
  * gates?: HostGate[],
434
+ * trace?: import("./lib/trace.mjs").TraceFn,
422
435
  * }} [opts]
423
436
  * @returns {Promise<void>}
424
437
  */
425
438
  export async function cliMain(opts = {}) {
426
- const { gates = [] } = opts;
439
+ const { gates = [], trace: emitTrace = trace } = opts;
427
440
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
428
441
  await runJudgeCli(
429
442
  HOOK_NAME,
430
- (event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
443
+ (event) =>
444
+ judgePreToolUseSanitize(event, undefined, {
445
+ messages,
446
+ gates,
447
+ trace: emitTrace,
448
+ }),
431
449
  {
432
450
  // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
433
451
  // hard-denies (adversary-inducible, no benefit to failing); any throw
@@ -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,17 @@ 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`.
198
206
  * @property {string} [remedy]
199
207
  * What a reader should run when the sanitizer's own bindings are what is
200
208
  * missing. This hook's host channel is `ext`, where the other two gates use a
@@ -572,13 +580,16 @@ export function emitFailClosed(
572
580
  * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
573
581
  */
574
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);
575
586
  /**
576
587
  * @param {string} outcome noop | clean | flagged | modified
577
588
  * @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
578
589
  * @returns {{ mutated_output?: unknown, additional_context?: string } | null}
579
590
  */
580
591
  const emit = (outcome, fields) => {
581
- trace(TraceEvent.HOOK_RAN, {
592
+ emitTrace(TraceEvent.HOOK_RAN, {
582
593
  hook: HOOK_NAME,
583
594
  tool: input.tool_name,
584
595
  outcome,
@@ -716,6 +727,10 @@ export async function judgeSanitizeOutput(event, ext = {}) {
716
727
  const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
717
728
  await ext.audit({
718
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,
719
734
  modified,
720
735
  output: modified ? fields?.mutated_output : event.response,
721
736
  context: fields?.additional_context,
@@ -27,7 +27,7 @@ import {
27
27
  DEFAULT_MISSING_PACKAGE_REMEDY,
28
28
  } from "./lib/hook-io.mjs";
29
29
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
30
- import { trace, TraceEvent } from "./lib/trace.mjs";
30
+ import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
31
31
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
32
32
  // come from the agent-sanitizer package. They are bound by a *caught* dynamic
33
33
  // import, never a bare top-level `import … from "…"`: a static npm import
@@ -159,20 +159,30 @@ export function judgeSanitizeUserPrompt(
159
159
  }
160
160
 
161
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.
162
165
  * @param {() => Promise<any> | any} read
163
166
  * @param {(chunk: string) => void} write
164
- * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
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
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).
168
177
  * @returns {Promise<void>}
169
178
  */
170
- export async function main(
171
- read,
172
- write,
173
- strip = stripAnsiFully,
174
- overrides = USER_PROMPT_MESSAGES,
175
- ) {
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);
176
186
  // Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
177
187
  // call site where a missing field would throw out of the catch and fail OPEN.
178
188
  const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
@@ -189,7 +199,7 @@ export async function main(
189
199
  const verdict = judgeSanitizeUserPrompt(event, strip, messages);
190
200
  // Announce engagement on the trace channel like the other stdin hooks —
191
201
  // a prompt gate that silently stopped running is otherwise invisible.
192
- trace(TraceEvent.HOOK_RAN, {
202
+ emitTrace(TraceEvent.HOOK_RAN, {
193
203
  hook: "sanitize-user-prompt",
194
204
  outcome:
195
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.1",
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,12 +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[],
77
+ * trace?: import("./lib/trace.mjs").TraceFn,
70
78
  * }} [opts]
71
79
  * @returns {Promise<void>}
72
80
  */
73
81
  export function cliMain(opts?: {
74
82
  messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
75
83
  gates?: HostGate[];
84
+ trace?: import("./lib/trace.mjs").TraceFn;
76
85
  }): Promise<void>;
77
86
  /**
78
87
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
@@ -27,9 +27,17 @@
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`.
33
41
  * @property {string} [remedy]
34
42
  * What a reader should run when the sanitizer's own bindings are what is
35
43
  * missing. This hook's host channel is `ext`, where the other two gates use a
@@ -264,14 +272,25 @@ export type SanitizeExtensions = {
264
272
  redactNote?: ((raw: string) => string | undefined) | undefined;
265
273
  /**
266
274
  * Awaited once per judged event that carried a tool response, with the output
267
- * 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.
268
279
  */
269
280
  audit?: ((record: {
270
281
  tool: string | null;
282
+ session_id?: string;
271
283
  modified: boolean;
272
284
  output: unknown;
273
285
  context?: string;
274
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;
275
294
  /**
276
295
  * What a reader should run when the sanitizer's own bindings are what is
277
296
  * missing. This hook's host channel is `ext`, where the other two gates use a
@@ -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
  /**
@@ -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 }}