agent-sanitizer 2.67.2 → 2.69.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.
Files changed (33) hide show
  1. package/README.md +8 -6
  2. package/THREAT-MODEL.md +52 -39
  3. package/claude-hooks/lib/env-config.mjs +2 -2
  4. package/claude-hooks/lib/hook-fault.mjs +13 -35
  5. package/claude-hooks/lib/hook-io.mjs +84 -9
  6. package/claude-hooks/lib/hook-timing.mjs +26 -6
  7. package/claude-hooks/lib/invisible-alert.mjs +6 -3
  8. package/claude-hooks/lib/redactor-client.mjs +23 -23
  9. package/claude-hooks/lib/secret-annotate.mjs +36 -66
  10. package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
  11. package/claude-hooks/plugin-hooks.mjs +30 -27
  12. package/claude-hooks/pretooluse-sanitize.mjs +288 -64
  13. package/claude-hooks/sanitize-output.mjs +27 -40
  14. package/claude-hooks/sanitize-user-prompt.mjs +28 -17
  15. package/claude-hooks/scan-invisible-chars.mjs +86 -35
  16. package/claude-hooks/scan-loaded-instructions.mjs +20 -16
  17. package/package.json +1 -1
  18. package/src/html.mjs +17 -17
  19. package/src/output.mjs +20 -19
  20. package/src/rehydrate.mjs +36 -9
  21. package/types/claude-hooks/lib/hook-fault.d.mts +8 -73
  22. package/types/claude-hooks/lib/hook-io.d.mts +32 -6
  23. package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
  24. package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
  25. package/types/claude-hooks/lib/redactor-client.d.mts +3 -3
  26. package/types/claude-hooks/lib/secret-annotate.d.mts +11 -17
  27. package/types/claude-hooks/lib/secret-drop-guard.d.mts +77 -9
  28. package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
  29. package/types/claude-hooks/sanitize-output.d.mts +1 -1
  30. package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
  31. package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
  32. package/types/output.d.mts +4 -5
  33. package/types/rehydrate.d.mts +8 -1
@@ -50,11 +50,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
50
50
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
51
51
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
52
52
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
53
- import {
54
- digestFlaggingEnabled,
55
- secretsEnabled,
56
- SECRETS_ENABLED_ENV,
57
- } from "./lib/env-config.mjs";
53
+ import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
58
54
  import {
59
55
  persistReveal,
60
56
  persistSpan,
@@ -120,16 +116,17 @@ const HOOK_NAME = "sanitize-output";
120
116
  // reaches the model-facing context. Exported so tests assert it by reference.
121
117
  export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
122
118
 
123
- // Total wall-clock budget for one hook invocation's blocking daemon calls — the
124
- // Layer-4 redactor — SHARED across every string leaf of the tool output. Each
125
- // call is handed the budget remaining at that moment; once it is spent, a further
126
- // secret-shaped leaf fails CLOSED (the redactor throws → the output is
127
- // suppressed). The per-call timeouts already bound each call, but not their SUM:
128
- // a structured output with many secret-shaped leaves could otherwise pile up past
129
- // the PostToolUse hook kill — a killed hook is non-blocking, so the RAW output
130
- // would be shown (fail OPEN). The default sits comfortably above one legitimately
131
- // slow leaf (a cold redactor respawn + a full scan) yet far under the hook
132
- // timeout; env-tunable so tests can drive the exhausted-budget path fast.
119
+ /**
120
+ * Total wall-clock budget for one hook invocation's blocking Layer-4 redactor
121
+ * calls, SHARED across every string leaf of the tool output. Each call gets the
122
+ * budget remaining at that moment; once it is spent, a further secret-shaped
123
+ * leaf throws, answered under the operator's fault posture. The per-call
124
+ * timeouts bound each call but not their SUM, and a structured output with many
125
+ * secret-shaped leaves could otherwise run past the PostToolUse hook kill,
126
+ * which shows the RAW output with no warning. The default sits above one slow
127
+ * leaf (a cold redactor respawn plus a full scan) and far under the hook
128
+ * timeout; env-tunable so tests can drive the exhausted-budget path fast.
129
+ */
133
130
  const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
134
131
 
135
132
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
@@ -172,11 +169,13 @@ function isUntrustedIngress(toolName) {
172
169
 
173
170
  /**
174
171
  * Redact secrets via the long-lived redactor daemon (lib/redactor-client.mjs).
175
- * Returns `{text, found}` or null when nothing was redacted; throws (fail closed)
176
- * when secret-shaped text cannot be vetted, which the caller turns into
177
- * suppression. The cheap pre-gate runs first so plain output never touches the
178
- * daemon. A transient daemon failure fails only THIS call — no session-wide
179
- * sentinel — and the client respawns a dead daemon on the next call.
172
+ * Returns `{text, found}` or null when nothing was redacted; throws when
173
+ * secret-shaped text cannot be vetted, which the CLI answers under the
174
+ * operator's fault posture. The cheap pre-gate runs first so plain output never
175
+ * touches the daemon. `text` is post-Layer-1, so it carries no terminal escape
176
+ * and the pre-gate needs no escape-stripped view. A transient daemon failure
177
+ * fails only THIS call — no session-wide sentinel — and the client respawns a
178
+ * dead daemon on the next call.
180
179
  * @param {string} text
181
180
  * @param {boolean} webIngress
182
181
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
@@ -288,9 +287,9 @@ export async function sanitizeText(
288
287
  // Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
289
288
  // redact callback at all, so plain output never spawns the daemon and no
290
289
  // placeholder ever enters the model's view. When it runs, the seam rethrows
291
- // a redactor throw wrapped, and this hook's fault policy suppresses the
292
- // output under either posture (alwaysClosed). Surface the failure to the
293
- // operator's terminal here first: the suppression note rides in what only
290
+ // a redactor throw wrapped, and this hook's fault policy answers it under
291
+ // the operator's AGENT_SANITIZER_FAIL_OPEN posture. Surface the failure to
292
+ // the operator's terminal here first: the fault note rides in what only
294
293
  // the model sees, so a degraded redactor would otherwise be invisible to
295
294
  // the human.
296
295
  redact: !secretsOn
@@ -579,7 +578,7 @@ function failClosedParts(
579
578
  * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
580
579
  * the original tool output in the model's view — or the fail-closed
581
580
  * suppression of {@link emitFailClosed} when the caller set
582
- * AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
581
+ * AGENT_SANITIZER_FAIL_OPEN=0.
583
582
  *
584
583
  * This is the hook where the two postures diverge the most, so state the open
585
584
  * one plainly: several of these layers throw on inputs an attacker composes (a
@@ -625,29 +624,17 @@ function suppressionMessage(cause) {
625
624
  return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
626
625
  }
627
626
 
628
- /** Why an open knob still suppressed: the secret layer's own guarantee. */
629
- const SECRETS_PIN_NOTE =
630
- ` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
631
- "not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
632
-
633
627
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
634
628
  // shared default (a warning context, the original output left in the model's
635
629
  // view); CLOSED replaces every string leaf of the output with the placeholder.
636
- // With Layer 4 opted in, a fault here means the output was never vetted for
637
- // secrets — a dead daemon, a spent budget, a walk that overflowed before the
638
- // redactor ran — so this pin is what keeps the open default from handing an
639
- // unredacted credential to the model.
630
+ // Layer 4 follows the same knob: a redactor that cannot run (a dead daemon, a
631
+ // spent budget) leaves the output unvetted and says so, rather than blocking
632
+ // every tool call on an availability failure.
640
633
  registerFaultPolicy(HOOK_NAME, {
641
634
  event: HookEvent.POST_TOOL_USE,
642
635
  guarded: "tool output",
643
- alwaysClosed: secretsEnabled,
644
636
  closed: (ctx) =>
645
- failClosedParts(
646
- ctx.input,
647
- suppressionMessage(ctx.message) +
648
- (ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
649
- ctx.remedy,
650
- ),
637
+ failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
651
638
  });
652
639
 
653
640
  /**
@@ -28,7 +28,6 @@ import {
28
28
  } from "./lib/hook-io.mjs";
29
29
  import {
30
30
  registerFaultPolicy,
31
- KNOB_DECIDES,
32
31
  hookFaultOutcome,
33
32
  writeFaultOutcome,
34
33
  } from "./lib/hook-fault.mjs";
@@ -78,6 +77,9 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
78
77
 
79
78
  const HOOK_NAME = "sanitize-user-prompt";
80
79
 
80
+ /** The `code` of the error a payload the adapter cannot classify raises. */
81
+ const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
82
+
81
83
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
82
84
  // shared default (a warning context alongside the prompt); CLOSED is a
83
85
  // top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
@@ -86,16 +88,18 @@ const HOOK_NAME = "sanitize-user-prompt";
86
88
  registerFaultPolicy(HOOK_NAME, {
87
89
  event: HookEvent.USER_PROMPT_SUBMIT,
88
90
  guarded: "prompt",
89
- alwaysClosed: KNOB_DECIDES,
90
- closed: (ctx) => ({
91
- envelope: {
92
- decision: "block",
93
- reason: {
94
- ...USER_PROMPT_MESSAGES,
95
- ...ctx.messages,
96
- }.hookFailed(ctx.message),
97
- },
98
- }),
91
+ closed: (ctx) => {
92
+ const err = /** @type {{ code?: unknown, reason?: string } | null} */ (
93
+ ctx.err
94
+ );
95
+ // An unclassifiable payload blocks with the host's own `unknownEvent`
96
+ // reason, carried on the error, rather than wrapped as a hook failure.
97
+ const reason =
98
+ err?.code === UNKNOWN_EVENT_CODE
99
+ ? /** @type {string} */ (err.reason)
100
+ : { ...USER_PROMPT_MESSAGES, ...ctx.messages }.hookFailed(ctx.message);
101
+ return { envelope: { decision: "block", reason } };
102
+ },
99
103
  });
100
104
 
101
105
  /* c8 ignore start — module-load boundary: the imports resolve in every real
@@ -123,8 +127,9 @@ registerFaultPolicy(HOOK_NAME, {
123
127
  * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
124
128
  * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
125
129
  * renders through any agent adapter, not just Claude's. Throws (into the
126
- * calling hook's catch) when the sanitizer package never loaded, so a prompt it
127
- * cannot classify takes the hook's fault posture rather than a silent ALLOW.
130
+ * calling hook's catch) when the sanitizer package never loaded or the payload
131
+ * is no recognizable event, so a prompt it cannot classify takes the hook's
132
+ * fault posture rather than a silent ALLOW.
128
133
  * @param {import("agent-control-plane-core").ToolCallEvent} event
129
134
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
130
135
  * to the package's stripAnsiFully; injectable so the fault path is testable)
@@ -144,11 +149,17 @@ export function judgeSanitizeUserPrompt(
144
149
  const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
145
150
  const { Decision, EventKind } = controlPlane();
146
151
  // A payload the adapter cannot classify carries no readable prompt, so an
147
- // abstain would fail OPEN on harness contract drift; this gate's posture is
148
- // deny-when-blind. (runJudgeCli's renderEscalated blocks it with exit 2 and
149
- // the reason on stderr, which Claude honors on UserPromptSubmit.)
152
+ // abstain would report a CLEAN run on harness contract drift. It is a fault:
153
+ // the throw lands in main()'s onError, and the registered posture decides —
154
+ // a warning by default, a block carrying the error's `reason` (this host's
155
+ // `messages.unknownEvent`) under AGENT_SANITIZER_FAIL_OPEN=0.
150
156
  if (event.event === EventKind.UNKNOWN)
151
- return { decision: Decision.DENY, reason: messages.unknownEvent };
157
+ throw Object.assign(
158
+ new Error(
159
+ "sanitize-user-prompt: unrecognized hook payload (not UserPromptSubmit)",
160
+ ),
161
+ { code: UNKNOWN_EVENT_CODE, reason: messages.unknownEvent },
162
+ );
152
163
  if (event.event !== EventKind.PROMPT_SUBMIT)
153
164
  return { decision: Decision.ALLOW };
154
165
  // The module-load guard, one arm per binding. The two lazyImports are
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
25
25
  import { relative, resolve } from "node:path";
26
26
  import {
27
27
  awaitLazyDependency,
28
- emitHookResponse,
28
+ createEnvelopeBuffer,
29
29
  safeErrMessage,
30
30
  hookgateMarkerPath,
31
31
  HookEvent,
@@ -39,7 +39,6 @@ import {
39
39
  } from "./lib/hook-io.mjs";
40
40
  import {
41
41
  registerFaultPolicy,
42
- KNOB_DECIDES,
43
42
  hookFaultOutcome,
44
43
  writeFaultOutcome,
45
44
  } from "./lib/hook-fault.mjs";
@@ -158,37 +157,76 @@ function faultLine(ctx) {
158
157
  );
159
158
  }
160
159
 
161
- // This hook's entry in the one posture table (lib/hook-fault.mjs). It has no
162
- // stdout verdict channel — SessionStart cannot deny — so BOTH arms are stated
163
- // explicitly rather than taking the shared additionalContext default: the only
164
- // enforcement a SessionStart hook can reach is the cross-hook alert, which makes
165
- // the PreToolUse gate ask once on the next tool call.
160
+ /**
161
+ * The open-posture parts for instruction files the scan could not READ, or null
162
+ * when the fault is anything else. Exit 0 with an envelope, not the fault arm's
163
+ * bare exit 1: the hook ran and is reporting what it could not check, so the
164
+ * user (systemMessage) and the model (additionalContext) are both told, and the
165
+ * launcher forwards it rather than reporting a broken bundle.
166
+ * @param {import("./lib/hook-fault.mjs").FaultContext} ctx
167
+ * @returns {import("./lib/hook-fault.mjs").FaultParts | null}
168
+ */
169
+ function openGapParts(ctx) {
170
+ if (ctx.gapNotice === undefined) return null;
171
+ const warning =
172
+ `${ctx.gapNotice}\n${HOOK_NAME}: failing open — nothing is blocked. Set ` +
173
+ "AGENT_SANITIZER_FAIL_OPEN=0 to have the next tool call ask instead.";
174
+ return {
175
+ stderr: `${warning}\n`,
176
+ envelope: {
177
+ systemMessage: warning,
178
+ hookSpecificOutput: {
179
+ hookEventName: HookEvent.SESSION_START,
180
+ additionalContext: warning,
181
+ },
182
+ },
183
+ };
184
+ }
185
+
186
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). SessionStart
187
+ // cannot deny, so the only enforcement either arm can reach is the cross-hook
188
+ // alert, which makes the PreToolUse gate ask once on the next tool call — and
189
+ // only the CLOSED arm arms it. An unreadable instruction file (`gapNotice`) is
190
+ // rendered here too: the scan worked, but whether a gap may stop the session is
191
+ // the same operator choice a fault is.
166
192
  registerFaultPolicy(HOOK_NAME, {
167
193
  event: HookEvent.SESSION_START,
168
194
  guarded: "instruction files",
169
- alwaysClosed: KNOB_DECIDES,
170
- open: (ctx) => ({
171
- stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
172
- exitCode: 1,
173
- }),
174
- closed: (ctx) => ({
175
- stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
176
- exitCode: 1,
177
- armAlert: true,
178
- }),
195
+ open: (ctx) =>
196
+ openGapParts(ctx) ?? {
197
+ stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
198
+ exitCode: 1,
199
+ },
200
+ closed: (ctx) =>
201
+ ctx.gapNotice === undefined
202
+ ? {
203
+ stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
204
+ exitCode: 1,
205
+ armAlert: true,
206
+ }
207
+ : { stderr: `${ctx.gapNotice}\n`, armAlert: true },
179
208
  });
180
209
 
181
210
  /**
182
- * Render this hook's fault under the declared posture, and return the text (if
183
- * any) that must ride in the cross-hook alert so a later gate carries the
184
- * posture this hook cannot express itself.
211
+ * Render this hook's fault (or, with `gapNotice`, its unreadable-file report)
212
+ * under the declared posture, and return the text (if any) that must ride in
213
+ * the cross-hook alert so a later gate carries the posture this hook cannot
214
+ * express itself.
185
215
  * @param {unknown} err
216
+ * @param {string | undefined} gapNotice the {@link formatSkipped} report
217
+ * @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
218
+ * buffer
186
219
  * @returns {string[]}
187
220
  */
188
- function reportFault(err) {
189
- const outcome = hookFaultOutcome(HOOK_NAME, err);
190
- process.exitCode = writeFaultOutcome(outcome);
191
- return outcome.armAlert ? [/** @type {string} */ (outcome.stderr)] : [];
221
+ function reportFault(err, gapNotice, out) {
222
+ const outcome = hookFaultOutcome(HOOK_NAME, err, { gapNotice });
223
+ // Buffered, not written: the timing wrapper may still owe stdout a slow-hook
224
+ // notice, and two envelopes on one stdout are invalid hook output.
225
+ process.exitCode = writeFaultOutcome(outcome, (chunk) =>
226
+ out.add(JSON.parse(chunk)),
227
+ );
228
+ if (!outcome.armAlert) return [];
229
+ return [gapNotice ?? /** @type {string} */ (outcome.stderr)];
192
230
  }
193
231
 
194
232
  /**
@@ -333,9 +371,9 @@ export function scanProject(dir = PROJECT_DIR) {
333
371
  }
334
372
 
335
373
  /**
336
- * The report for targets the scan could not read. Rendered into the alert the
337
- * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
338
- * checkpoint rather than as silence.
374
+ * The report for targets the scan could not read: a SessionStart warning under
375
+ * the open posture, and the alert the PreToolUse gate surfaces under the closed
376
+ * one — either way an incomplete scan reaches the operator rather than silence.
339
377
  * @param {Array<{file: string, reason: string}>} skipped
340
378
  * @returns {string}
341
379
  */
@@ -385,12 +423,15 @@ export async function cliMain(opts = {}) {
385
423
  // reporting an overrun in band is what turned a 30-second scan from a rumor
386
424
  // into a bug report (see lib/hook-timing.mjs).
387
425
  const timer = startHookTimer();
426
+ // One stdout document per run: the scan's own envelope (a fault or the
427
+ // unreadable-file notice) and the slow-hook notice below fold into it.
428
+ const out = createEnvelopeBuffer();
388
429
  try {
389
430
  // Charged to provisioning when the session's setup is installing THROUGHOUT
390
431
  // this run: that install saturates the machine, and a scan that merely
391
432
  // waited it out has no per-call cost to report (see hook-timing.mjs).
392
433
  await excludeConcurrentProvisioning(
393
- () => runScanCli(opts),
434
+ () => runScanCli(opts, out),
394
435
  () => setupRunning(hookgateMarkerPath()),
395
436
  );
396
437
  } finally {
@@ -398,7 +439,7 @@ export async function cliMain(opts = {}) {
398
439
  HOOK_NAME,
399
440
  timer.wallMs(),
400
441
  HookEvent.SESSION_START,
401
- emitHookResponse,
442
+ out.emit,
402
443
  undefined,
403
444
  // All four windows, including the two this scan normally leaves empty: a
404
445
  // measured 0 rules a window OUT, where an omitted one leaves the notice
@@ -409,6 +450,7 @@ export async function cliMain(opts = {}) {
409
450
  hostMs: timer.hostMs(),
410
451
  },
411
452
  );
453
+ out.flush();
412
454
  }
413
455
  }
414
456
 
@@ -421,9 +463,10 @@ export async function cliMain(opts = {}) {
421
463
  * scan?: () => ReturnType<typeof scanProject>,
422
464
  * sessionId?: string,
423
465
  * }} opts see {@link cliMain}
466
+ * @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out see {@link cliMain}
424
467
  * @returns {Promise<void>}
425
468
  */
426
- async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
469
+ async function runScanCli({ trace: sink, scan: runScan, sessionId }, out) {
427
470
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
428
471
  // the alert write, with no catch above them, so a throwing host sink would
429
472
  // abort the scan silently (see hookTrace).
@@ -454,9 +497,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
454
497
  "agent-sanitizer failed to load (node deps not installed and " +
455
498
  "session-setup did not finish in time); run `pnpm install`",
456
499
  ),
500
+ undefined,
501
+ out,
457
502
  ),
458
503
  );
459
504
  persistAlert(alertParts, sessionId);
505
+ // process.exit skips cliMain's `finally`, which would otherwise flush.
506
+ out.flush();
460
507
  process.exit(1);
461
508
  }
462
509
  /* c8 ignore stop */
@@ -482,14 +529,14 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
482
529
  scan = (runScan ?? scanProject)();
483
530
  } catch (err) {
484
531
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
485
- alertParts.push(...reportFault(err));
532
+ alertParts.push(...reportFault(err, undefined, out));
486
533
  persistAlert(alertParts, sessionId);
487
534
  return;
488
535
  }
489
536
  const { findings: allFindings, skipped, absent, scanned } = scan;
490
537
 
491
538
  // The three buckets of classifyReadFailure, rendered: only `skipped` may
492
- // withhold "clean" and arm the gate.
539
+ // withhold "clean", and it arms the gate only under the closed posture.
493
540
  if (skipped.length > 0) {
494
541
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
495
542
  outcome: "partial",
@@ -498,9 +545,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
498
545
  absent: absent.length,
499
546
  files: allFindings.length,
500
547
  });
501
- const notice = formatSkipped(skipped);
502
- process.stderr.write(notice + "\n");
503
- alertParts.push(notice);
548
+ alertParts.push(
549
+ ...reportFault(
550
+ new Error(`${skipped.length} instruction file(s) could not be read`),
551
+ formatSkipped(skipped),
552
+ out,
553
+ ),
554
+ );
504
555
  } else if (allFindings.length === 0) {
505
556
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
506
557
  outcome: "clean",
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { readFileSync } from "node:fs";
21
21
  import {
22
- emitHookResponse,
22
+ createEnvelopeBuffer,
23
23
  EmptyStdinError,
24
24
  hookgateMarkerPath,
25
25
  HookEvent,
@@ -32,7 +32,6 @@ import {
32
32
  } from "./lib/hook-io.mjs";
33
33
  import {
34
34
  registerFaultPolicy,
35
- KNOB_DECIDES,
36
35
  hookFaultOutcome,
37
36
  writeFaultOutcome,
38
37
  } from "./lib/hook-fault.mjs";
@@ -123,7 +122,6 @@ function emptyPayloadParts(ctx) {
123
122
  registerFaultPolicy(HOOK_NAME, {
124
123
  event: HookEvent.INSTRUCTIONS_LOADED,
125
124
  guarded: "a loaded instruction file",
126
- alwaysClosed: KNOB_DECIDES,
127
125
  open: (ctx) =>
128
126
  emptyPayloadParts(ctx) ?? {
129
127
  stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
@@ -270,12 +268,15 @@ export { HOOK_NAME };
270
268
  export async function cliMain({ trace: sink } = {}) {
271
269
  const timer = startHookTimer();
272
270
  const emitTrace = hookTrace(sink);
271
+ // One stdout document per run: the finding/fault envelope and the slow-hook
272
+ // notice below fold into it, never written as two.
273
+ const out = createEnvelopeBuffer();
273
274
  try {
274
275
  // Charged to provisioning when the session's setup is installing THROUGHOUT
275
276
  // this run: that install saturates the machine, and a scan that merely
276
277
  // waited it out has no per-call cost to report (see hook-timing.mjs).
277
278
  await excludeConcurrentProvisioning(
278
- () => runLoadedScanCli(emitTrace),
279
+ () => runLoadedScanCli(emitTrace, out),
279
280
  () => setupRunning(hookgateMarkerPath()),
280
281
  );
281
282
  } finally {
@@ -283,7 +284,7 @@ export async function cliMain({ trace: sink } = {}) {
283
284
  HOOK_NAME,
284
285
  timer.wallMs(),
285
286
  HookEvent.INSTRUCTIONS_LOADED,
286
- emitHookResponse,
287
+ out.emit,
287
288
  undefined,
288
289
  // All four windows, including the two this scan normally leaves empty: a
289
290
  // measured 0 rules a window OUT, where an omitted one leaves the notice
@@ -294,6 +295,7 @@ export async function cliMain({ trace: sink } = {}) {
294
295
  hostMs: timer.hostMs(),
295
296
  },
296
297
  );
298
+ out.flush();
297
299
  }
298
300
  }
299
301
 
@@ -302,9 +304,11 @@ export async function cliMain({ trace: sink } = {}) {
302
304
  * single call to bracket — every early return here is an exit that wrapper must
303
305
  * still measure.
304
306
  * @param {import("./lib/trace.mjs").TraceFn} emitTrace
307
+ * @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
308
+ * buffer
305
309
  * @returns {Promise<void>}
306
310
  */
307
- async function runLoadedScanCli(emitTrace) {
311
+ async function runLoadedScanCli(emitTrace, out) {
308
312
  /** @type {string | undefined} */
309
313
  let sessionId;
310
314
  try {
@@ -342,19 +346,19 @@ async function runLoadedScanCli(emitTrace) {
342
346
  // systemMessage reaches the user, additionalContext the model. Both, because
343
347
  // this hook cannot block and the file is already loaded: the user is the one
344
348
  // who can act on it, and the model is the one currently reading it.
345
- process.stdout.write(
346
- JSON.stringify({
347
- systemMessage: message,
348
- hookSpecificOutput: {
349
- hookEventName: HookEvent.INSTRUCTIONS_LOADED,
350
- additionalContext: message,
351
- },
352
- }),
353
- );
349
+ out.add({
350
+ systemMessage: message,
351
+ hookSpecificOutput: {
352
+ hookEventName: HookEvent.INSTRUCTIONS_LOADED,
353
+ additionalContext: message,
354
+ },
355
+ });
354
356
  } catch (err) {
355
357
  emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
356
358
  const outcome = hookFaultOutcome(HOOK_NAME, err);
357
- process.exitCode = writeFaultOutcome(outcome);
359
+ process.exitCode = writeFaultOutcome(outcome, (chunk) =>
360
+ out.add(JSON.parse(chunk)),
361
+ );
358
362
  // `sessionId`, captured before the throw: a payload read that itself failed
359
363
  // leaves it undefined, and the fault then lands in the shared fallback store
360
364
  // rather than in no store at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.67.2",
3
+ "version": "2.69.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": {
package/src/html.mjs CHANGED
@@ -1592,16 +1592,17 @@ function isSelfClosedForeign(tagName, value) {
1592
1592
  // under fragment parsing (scripting disabled) parse5 parses its content as
1593
1593
  // normal markup, so scanning it is correct. Once `plaintext` opens it runs to
1594
1594
  // EOF and never closes.
1595
- const RAW_TEXT_ELEMENTS = new Set([
1596
- "script",
1597
- "style",
1598
- "textarea",
1599
- "title",
1600
- "xmp",
1601
- "iframe",
1602
- "noembed",
1603
- "noframes",
1604
- "plaintext",
1595
+ // Each end-tag matcher is spelled per tag, so none compiles a document's text.
1596
+ const RAW_TEXT_END_TAGS = new Map([
1597
+ ["script", /<\/script(?![a-z0-9-])/i],
1598
+ ["style", /<\/style(?![a-z0-9-])/i],
1599
+ ["textarea", /<\/textarea(?![a-z0-9-])/i],
1600
+ ["title", /<\/title(?![a-z0-9-])/i],
1601
+ ["xmp", /<\/xmp(?![a-z0-9-])/i],
1602
+ ["iframe", /<\/iframe(?![a-z0-9-])/i],
1603
+ ["noembed", /<\/noembed(?![a-z0-9-])/i],
1604
+ ["noframes", /<\/noframes(?![a-z0-9-])/i],
1605
+ ["plaintext", /<\/plaintext(?![a-z0-9-])/i],
1605
1606
  ]);
1606
1607
 
1607
1608
  // Keywords that take the parent's value of an inherited property: `revert`
@@ -2608,9 +2609,9 @@ function scanInlineChildren(node, text, ranges, warned) {
2608
2609
  // "Inside an unterminated tag / bogus comment" — parse5 absorbs following
2609
2610
  // markup into it until the next `>`.
2610
2611
  let absorbing = false;
2611
- // Non-null while inside a raw-text element (its lowercased tag name); content
2612
- // is opaque until the matching end tag.
2613
- let rawText = /** @type {string | null} */ (null);
2612
+ // Non-null while inside a raw-text element (the matcher for its end tag);
2613
+ // content is opaque until that end tag.
2614
+ let rawTextEnd = /** @type {RegExp | null} */ (null);
2614
2615
  // End offset of the last html node processed; the raw slice from here to the
2615
2616
  // next html node is what parse5 tokenizes between them.
2616
2617
  let prevEnd = node.position.start.offset;
@@ -2622,10 +2623,9 @@ function scanInlineChildren(node, text, ranges, warned) {
2622
2623
  // into the absorb state before deciding what to do with this html node.
2623
2624
  absorbing = foldAbsorb(absorbing, text.slice(prevEnd, base));
2624
2625
 
2625
- if (rawText) {
2626
+ if (rawTextEnd) {
2626
2627
  // Raw-text content is opaque; only the matching end tag ends the region.
2627
- if (new RegExp(`</${rawText}(?![a-z0-9-])`, "i").test(value))
2628
- rawText = null;
2628
+ if (rawTextEnd.test(value)) rawTextEnd = null;
2629
2629
  } else if (state.depth > 0) {
2630
2630
  updateHiddenState(state, value, end, ranges);
2631
2631
  } else if (!absorbing) {
@@ -2655,7 +2655,7 @@ function scanInlineChildren(node, text, ranges, warned) {
2655
2655
  if (el) {
2656
2656
  // A raw-text open tag starts an opaque region (a self-closing `/>`
2657
2657
  // does not apply to these in HTML — they always open).
2658
- if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
2658
+ rawTextEnd = RAW_TEXT_END_TAGS.get(el.tagName) ?? null;
2659
2659
  // `el` is one tag parsed alone, so a `<template>` inside an inline
2660
2660
  // `<svg>`/`<math>` reads as HTML here and is reported, where the
2661
2661
  // source branch, which sees the namespace, does not: this errs toward