agent-sanitizer 2.57.0 → 2.57.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.
package/README.md CHANGED
@@ -357,8 +357,13 @@ the extension bag's `trace` on
357
357
  receives the same `TraceEvent` names the default emits, and it **replaces** the
358
358
  default rather than running alongside it — the package channel goes silent, so
359
359
  there is one announcement to detect, not two. It may throw freely: each hook
360
- binds the sink it is given through `bestEffortTrace`, so an announcement can
361
- never be the thing that breaks a hook.
360
+ binds the sink it is given through `hookTrace`, so an announcement can never be
361
+ the thing that breaks a hook.
362
+
363
+ That same binding charges whatever a host sink spends — a write to an unanswered
364
+ socket, a subprocess — to the slow-hook notice's host-extension window. A hook
365
+ past its budget then names the sink, instead of leaving the wait in the
366
+ unattributed remainder and the sink's in-process CPU billed to the sanitizer.
362
367
 
363
368
  **A host's own cold-start marker can replace the derived one.** The hooks wait
364
369
  out an in-flight dependency install by polling a marker file whose path they
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { appendFileSync } from "node:fs";
22
+ import { chargeHostExtensionSync } from "./hook-timing.mjs";
22
23
 
23
24
  /**
24
25
  * The sink shape a hook emits through: the event name, its metadata fields, and
@@ -26,7 +27,7 @@ import { appendFileSync } from "node:fs";
26
27
  * default emits, so it can remap them onto its own channel's vocabulary.
27
28
  *
28
29
  * 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
+ * was given through {@link hookTrace}, which is what upholds the channel's
30
31
  * never-breaks-a-hook posture on host code that cannot promise it.
31
32
  * @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
32
33
  */
@@ -105,3 +106,30 @@ export function bestEffortTrace(sink) {
105
106
  }
106
107
  };
107
108
  }
109
+
110
+ /**
111
+ * The sink a hook emits through, given a host's or none: a HOST sink is made
112
+ * best-effort and charged to the slow-hook notice's host-extension window; the
113
+ * package's own {@link trace} is handed back untouched.
114
+ *
115
+ * A composer's sink may write over a socket or spawn a subprocess whose cost
116
+ * this process cannot see, so an uncharged one leaves its wait in the notice's
117
+ * unattributed remainder and its in-process CPU billed to the sanitizer. The
118
+ * default sink's file write IS the sanitizer's own work, so charging it would
119
+ * move a real per-call cost out of the figure that names it.
120
+ *
121
+ * Both properties are bound HERE, not at each hook, so a sixth caller can
122
+ * neither drop one nor nest them wrong — and the nesting is load-bearing twice
123
+ * over. The charge is booked in a `finally` inside the best-effort bracket, so
124
+ * a sink that spends its wait and THEN throws is measured before the throw is
125
+ * swallowed. And the exemption above reads the host's own sink, which a
126
+ * best-effort wrapper applied first would have replaced with a truthy one.
127
+ * @param {TraceFn} [sink] a host's sink; absent asks for the package channel
128
+ * @returns {TraceFn}
129
+ */
130
+ export function hookTrace(sink) {
131
+ if (!sink) return trace;
132
+ return bestEffortTrace((event, fields, level) =>
133
+ chargeHostExtensionSync(() => sink(event, fields, level)),
134
+ );
135
+ }
@@ -73,7 +73,7 @@ import {
73
73
  LAYER2_PLACEHOLDER_RE,
74
74
  } from "./lib/placeholder-grammar.mjs";
75
75
  import { readSpan, spanPath } from "./lib/reveal.mjs";
76
- import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
76
+ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
77
77
 
78
78
  const HOOK_NAME = "pretooluse-sanitize";
79
79
 
@@ -474,11 +474,11 @@ function toolTargetDir(tool, toolInput) {
474
474
  export async function buildPreToolUseResponse(
475
475
  input,
476
476
  rehydrate = defaultRehydrate,
477
- sink = trace,
477
+ sink,
478
478
  ) {
479
- // Every path into the announcement runs through here, so this is the one place
480
- // a host sink has to be made best-effort (see bestEffortTrace).
481
- const emitTrace = bestEffortTrace(sink);
479
+ // Every path into the announcement runs through here, so this is the one
480
+ // place a host sink has to be bound (see hookTrace).
481
+ const emitTrace = hookTrace(sink);
482
482
  const asks = [];
483
483
  const contexts = [];
484
484
 
@@ -618,7 +618,11 @@ function assembleResponse({
618
618
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
619
619
  */
620
620
  export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
621
- const { gates = [], trace: emitTrace = trace } = opts;
621
+ // Forwarded UNRESOLVED: substituting the package sink for an absent one here
622
+ // would hand hookTrace a truthy sink, charging this package's own trace write
623
+ // to the host window — the misattribution the split exists to prevent. Only
624
+ // buildPreToolUseResponse's binding may pick the default.
625
+ const { gates = [], trace: sink } = opts;
622
626
  // MERGED over the defaults, never substituted for them. A host that overrides
623
627
  // one field would otherwise leave the rest undefined, and the miss lands in
624
628
  // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
@@ -653,7 +657,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
653
657
  const denyReason = gate(input);
654
658
  if (denyReason) return { decision: Decision.DENY, reason: denyReason };
655
659
  }
656
- const fields = await buildPreToolUseResponse(input, rehydrate, emitTrace);
660
+ const fields = await buildPreToolUseResponse(input, rehydrate, sink);
657
661
  if (fields === null) return { decision: Decision.ALLOW };
658
662
  /** @type {Record<string, unknown>} */
659
663
  const verdict = {
@@ -897,7 +901,8 @@ registerFaultPolicy(HOOK_NAME, {
897
901
  * @returns {Promise<void>}
898
902
  */
899
903
  export async function cliMain(opts = {}) {
900
- const { gates = [], trace: emitTrace = trace } = opts;
904
+ // Unresolved, for the reason judgePreToolUseSanitize states.
905
+ const { gates = [], trace: sink } = opts;
901
906
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
902
907
  await runJudgeCli(
903
908
  HOOK_NAME,
@@ -905,7 +910,7 @@ export async function cliMain(opts = {}) {
905
910
  judgePreToolUseSanitize(event, undefined, {
906
911
  messages,
907
912
  gates,
908
- trace: emitTrace,
913
+ trace: sink,
909
914
  }),
910
915
  {
911
916
  // The caller's posture, WITHOUT the package: pass through with a warning
@@ -47,7 +47,7 @@ import {
47
47
  } from "./lib/hook-io.mjs";
48
48
  import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
49
49
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
50
- import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
50
+ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
51
51
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
52
52
  import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
53
53
  import {
@@ -856,18 +856,9 @@ registerFaultPolicy(HOOK_NAME, {
856
856
  * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
857
857
  */
858
858
  export async function evaluateToolOutput(input, ext = {}) {
859
- // Best-effort, like the default sink: a host callback that throws must not be
860
- // the thing that suppresses a tool output (see bestEffortTrace). A COMPOSER's
861
- // sink is charged to the host window — it may write over a socket this process
862
- // cannot see the cost of — while the package's own sink is not, since that
863
- // file write is the sanitizer's own work.
864
- const hostTrace = ext.trace;
865
- const emitTrace = bestEffortTrace(
866
- hostTrace
867
- ? (event, fields) =>
868
- chargeHostExtensionSync(() => hostTrace(event, fields))
869
- : trace,
870
- );
859
+ // A host callback that throws must not be the thing that suppresses a tool
860
+ // output (see hookTrace).
861
+ const emitTrace = hookTrace(ext.trace);
871
862
  /**
872
863
  * @param {string} outcome noop | clean | flagged | modified
873
864
  * @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
@@ -32,7 +32,7 @@ import {
32
32
  writeFaultOutcome,
33
33
  } from "./lib/hook-fault.mjs";
34
34
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
35
- import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
35
+ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
36
36
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
37
37
  // come from the agent-sanitizer package. They are bound by a *caught* dynamic
38
38
  // import, never a bare top-level `import … from "…"`: a static npm import
@@ -208,10 +208,10 @@ export async function main(read, write, opts = {}) {
208
208
  const {
209
209
  strip = stripAnsiFully,
210
210
  overrides = USER_PROMPT_MESSAGES,
211
- trace: sink = trace,
211
+ trace: sink,
212
212
  env = process.env,
213
213
  } = opts;
214
- const emitTrace = bestEffortTrace(sink);
214
+ const emitTrace = hookTrace(sink);
215
215
  // Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
216
216
  // call site where a missing field would throw out of the catch and fail OPEN.
217
217
  const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
@@ -51,7 +51,7 @@ import {
51
51
  import { formatReport } from "./lib/invisible-report.mjs";
52
52
  import { sweepStaleReveals } from "./lib/reveal.mjs";
53
53
  import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
54
- import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
54
+ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
55
55
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
56
56
  // Relative, not the `agent-sanitizer` specifier every other engine import uses:
57
57
  // this is the scan's SCOPE, which is hook policy, and package.json's exports map
@@ -412,7 +412,14 @@ export async function cliMain(opts = {}) {
412
412
  HookEvent.SESSION_START,
413
413
  emitHookResponse,
414
414
  undefined,
415
- { cpuMs: timer.cpuMs() },
415
+ // All four windows, including the two this scan normally leaves empty: a
416
+ // measured 0 rules a window OUT, where an omitted one leaves the notice
417
+ // naming candidates it cannot separate.
418
+ {
419
+ cpuMs: timer.cpuMs(),
420
+ redactorMs: timer.redactorMs(),
421
+ hostMs: timer.hostMs(),
422
+ },
416
423
  );
417
424
  }
418
425
  }
@@ -428,11 +435,11 @@ export async function cliMain(opts = {}) {
428
435
  * }} opts see {@link cliMain}
429
436
  * @returns {Promise<void>}
430
437
  */
431
- async function runScanCli({ trace: sink = trace, scan: runScan, sessionId }) {
438
+ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
432
439
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
433
440
  // the alert write, with no catch above them, so a throwing host sink would
434
- // abort the scan silently (see bestEffortTrace).
435
- const emitTrace = bestEffortTrace(sink);
441
+ // abort the scan silently (see hookTrace).
442
+ const emitTrace = hookTrace(sink);
436
443
  // Everything the PreToolUse gate must surface this session, written once at
437
444
  // the end: an incomplete scan and an uncleanable file are independent reasons
438
445
  // to arm the gate, and two separate writes would have the second clobber the
@@ -37,7 +37,7 @@ import {
37
37
  appendAlert,
38
38
  recordInstructionsLoaded,
39
39
  } from "./lib/invisible-alert.mjs";
40
- import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
40
+ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
41
41
  import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
42
42
  import { formatReport } from "./lib/invisible-report.mjs";
43
43
  import {
@@ -256,9 +256,9 @@ export { HOOK_NAME };
256
256
  * passes its sink (see lib/trace.mjs)
257
257
  * @returns {Promise<void>}
258
258
  */
259
- export async function cliMain({ trace: sink = trace } = {}) {
259
+ export async function cliMain({ trace: sink } = {}) {
260
260
  const timer = startHookTimer();
261
- const emitTrace = bestEffortTrace(sink);
261
+ const emitTrace = hookTrace(sink);
262
262
  /** @type {string | undefined} */
263
263
  let sessionId;
264
264
  try {
@@ -321,7 +321,14 @@ export async function cliMain({ trace: sink = trace } = {}) {
321
321
  HookEvent.INSTRUCTIONS_LOADED,
322
322
  emitHookResponse,
323
323
  undefined,
324
- { cpuMs: timer.cpuMs() },
324
+ // All four windows, including the two this scan normally leaves empty: a
325
+ // measured 0 rules a window OUT, where an omitted one leaves the notice
326
+ // naming candidates it cannot separate.
327
+ {
328
+ cpuMs: timer.cpuMs(),
329
+ redactorMs: timer.redactorMs(),
330
+ hostMs: timer.hostMs(),
331
+ },
325
332
  );
326
333
  }
327
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.57.0",
3
+ "version": "2.57.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": {
@@ -33,13 +33,34 @@ export function trace(event: string, fields?: Record<string, unknown>, level?: "
33
33
  * @returns {TraceFn}
34
34
  */
35
35
  export function bestEffortTrace(sink: TraceFn): TraceFn;
36
+ /**
37
+ * The sink a hook emits through, given a host's or none: a HOST sink is made
38
+ * best-effort and charged to the slow-hook notice's host-extension window; the
39
+ * package's own {@link trace} is handed back untouched.
40
+ *
41
+ * A composer's sink may write over a socket or spawn a subprocess whose cost
42
+ * this process cannot see, so an uncharged one leaves its wait in the notice's
43
+ * unattributed remainder and its in-process CPU billed to the sanitizer. The
44
+ * default sink's file write IS the sanitizer's own work, so charging it would
45
+ * move a real per-call cost out of the figure that names it.
46
+ *
47
+ * Both properties are bound HERE, not at each hook, so a sixth caller can
48
+ * neither drop one nor nest them wrong — and the nesting is load-bearing twice
49
+ * over. The charge is booked in a `finally` inside the best-effort bracket, so
50
+ * a sink that spends its wait and THEN throws is measured before the throw is
51
+ * swallowed. And the exemption above reads the host's own sink, which a
52
+ * best-effort wrapper applied first would have replaced with a truthy one.
53
+ * @param {TraceFn} [sink] a host's sink; absent asks for the package channel
54
+ * @returns {TraceFn}
55
+ */
56
+ export function hookTrace(sink?: TraceFn): TraceFn;
36
57
  /**
37
58
  * The sink shape a hook emits through: the event name, its metadata fields, and
38
59
  * the level. A host implementation receives the same {@link TraceEvent} names the
39
60
  * default emits, so it can remap them onto its own channel's vocabulary.
40
61
  *
41
62
  * 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
63
+ * was given through {@link hookTrace}, which is what upholds the channel's
43
64
  * never-breaks-a-hook posture on host code that cannot promise it.
44
65
  * @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
45
66
  */
@@ -55,7 +76,7 @@ export const TraceEvent: Readonly<{
55
76
  * default emits, so it can remap them onto its own channel's vocabulary.
56
77
  *
57
78
  * A sink is NOT required to be total — throw freely. Every hook binds the one it
58
- * was given through {@link bestEffortTrace}, which is what upholds the channel's
79
+ * was given through {@link hookTrace}, which is what upholds the channel's
59
80
  * never-breaks-a-hook posture on host code that cannot promise it.
60
81
  */
61
82
  export type TraceFn = (event: string, fields?: Record<string, unknown>, level?: "info" | "debug") => void;