agent-sanitizer 2.19.3 → 2.19.4

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.
@@ -26,15 +26,13 @@ import {
26
26
  lazyImport,
27
27
  emitHookResponse,
28
28
  errMessage,
29
- safeErrMessage,
30
- failOpenEnabled,
31
- failOpenContext,
32
29
  makeDeadline,
33
30
  lazyImportErrorFor,
34
31
  missingPackageMessage,
35
32
  DEFAULT_MISSING_PACKAGE_REMEDY,
36
33
  HookEvent,
37
34
  } from "./lib/hook-io.mjs";
35
+ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
38
36
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
39
37
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
40
38
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
@@ -552,19 +550,48 @@ export function emitFailClosed(
552
550
  message,
553
551
  emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
554
552
  remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
553
+ ) {
554
+ const { fields, fallbackFields } = failClosedParts(input, message, remedy);
555
+ try {
556
+ emit(fields);
557
+ } catch {
558
+ emit(fallbackFields);
559
+ }
560
+ }
561
+
562
+ /**
563
+ * The fail-closed response fields plus the shallow fallback to emit if
564
+ * serializing them throws. Split out from {@link emitFailClosed} so the posture
565
+ * table can state this hook's CLOSED verdict as a VALUE — the table is what
566
+ * `test/claude-hooks-posture.test.mjs` compares each hook's emission against, so
567
+ * a verdict reachable only by running the emitter could not be pinned there.
568
+ * @param {any} input parsed hook input, or undefined if parsing threw
569
+ * @param {string} message
570
+ * @param {string} [remedy] what a reader should run; hosts pass their own
571
+ * @returns {{ fields: Record<string, unknown>, fallbackFields: Record<string, unknown> }}
572
+ */
573
+ function failClosedParts(
574
+ input,
575
+ message,
576
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
555
577
  ) {
556
578
  // Threaded rather than defaulted here: this is the ONLY production caller of
557
579
  // failClosedContext, so a remedy it does not pass is a remedy no host can ever
558
580
  // reach — the parameter would be live only from tests.
559
581
  const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
582
+ const fallbackFields = { updatedToolOutput: message, additionalContext };
583
+ let updatedToolOutput;
560
584
  try {
561
- emit({
562
- updatedToolOutput: failClosedReplacement(input, message),
563
- additionalContext,
564
- });
585
+ updatedToolOutput = failClosedReplacement(input, message);
565
586
  } catch {
566
- emit({ updatedToolOutput: message, additionalContext });
587
+ // The shape-matching walk overflowed on a pathologically deep (but valid)
588
+ // tool_response. The bare string is shallow, always serializable, and still
589
+ // a valid string tool_response — so the hook stays CLOSED rather than
590
+ // throwing out of its own failure path, which would emit nothing and let
591
+ // the harness show the raw, unvetted output.
592
+ return { fields: fallbackFields, fallbackFields };
567
593
  }
594
+ return { fields: { updatedToolOutput, additionalContext }, fallbackFields };
568
595
  }
569
596
 
570
597
  /**
@@ -595,20 +622,40 @@ export function emitHookFailure(
595
622
  remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
596
623
  env = process.env,
597
624
  ) {
598
- if (failOpenEnabled(env)) {
599
- emit({
600
- additionalContext: failOpenContext("sanitize-output", "tool output", err),
601
- });
602
- return;
625
+ const outcome = hookFaultOutcome(HOOK_NAME, err, { input, remedy, env });
626
+ const fields = /** @type {Record<string, unknown>} */ (outcome.fields);
627
+ try {
628
+ emit(fields);
629
+ } catch (emitErr) {
630
+ // The open posture's fields are a lone string context — always
631
+ // serializable — so it declares no fallback, and a throw there is a real
632
+ // bug the caller must see rather than a suppression to retry.
633
+ if (outcome.fallbackFields === null) throw emitErr;
634
+ emit(outcome.fallbackFields);
603
635
  }
604
- emitFailClosed(
605
- input,
606
- `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${safeErrMessage(err)}]`,
607
- emit,
608
- remedy,
609
- );
610
636
  }
611
637
 
638
+ /**
639
+ * The suppression placeholder that replaces the tool output under the closed
640
+ * posture. Named so the posture table and {@link emitFailClosed} cannot drift on
641
+ * the wording the model sees.
642
+ * @param {string} cause the scrubbed hook error
643
+ * @returns {string}
644
+ */
645
+ function suppressionMessage(cause) {
646
+ return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
647
+ }
648
+
649
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
650
+ // shared default (a warning context, the original output left in the model's
651
+ // view); CLOSED replaces every string leaf of the output with the placeholder.
652
+ registerFaultPolicy(HOOK_NAME, {
653
+ event: HookEvent.POST_TOOL_USE,
654
+ guarded: "tool output",
655
+ closed: (ctx) =>
656
+ failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
657
+ });
658
+
612
659
  /**
613
660
  * Run the sanitization pipeline over a tool output and return the contract-
614
661
  * shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
@@ -20,15 +20,17 @@
20
20
  */
21
21
  import {
22
22
  readStdinJson,
23
- safeErrMessage,
24
- failOpenEnabled,
25
- failOpenContext,
26
23
  HookEvent,
27
24
  isMain,
28
25
  lazyImport,
29
26
  missingPackageError,
30
27
  DEFAULT_MISSING_PACKAGE_REMEDY,
31
28
  } from "./lib/hook-io.mjs";
29
+ import {
30
+ registerFaultPolicy,
31
+ hookFaultOutcome,
32
+ writeFaultOutcome,
33
+ } from "./lib/hook-fault.mjs";
32
34
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
33
35
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
34
36
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
@@ -73,6 +75,27 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
73
75
  remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
74
76
  });
75
77
 
78
+ const HOOK_NAME = "sanitize-user-prompt";
79
+
80
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
81
+ // shared default (a warning context alongside the prompt); CLOSED is a
82
+ // top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
83
+ // UserPromptSubmit has no permissionDecision channel, so this envelope shape is
84
+ // the gate's own and the table records it rather than a reader inferring it.
85
+ registerFaultPolicy(HOOK_NAME, {
86
+ event: HookEvent.USER_PROMPT_SUBMIT,
87
+ guarded: "prompt",
88
+ closed: (ctx) => ({
89
+ envelope: {
90
+ decision: "block",
91
+ reason: {
92
+ ...USER_PROMPT_MESSAGES,
93
+ ...ctx.messages,
94
+ }.hookFailed(ctx.message),
95
+ },
96
+ }),
97
+ });
98
+
76
99
  /* c8 ignore start — module-load boundary: the imports resolve in every real
77
100
  * run, and their failure (the package absent) can't be simulated in-process, so
78
101
  * neither arm is observable to the in-process tests. The judge's typeof guard
@@ -202,13 +225,13 @@ export async function main(read, write, opts = {}) {
202
225
  // exactly what may have failed to load. Either way this is the HOOK failing;
203
226
  // a prompt the working stripper flagged is still blocked in both postures.
204
227
  await runJudgeCli(
205
- "sanitize-user-prompt",
228
+ HOOK_NAME,
206
229
  (event) => {
207
230
  const verdict = judgeSanitizeUserPrompt(event, strip, messages);
208
231
  // Announce engagement on the trace channel like the other stdin hooks —
209
232
  // a prompt gate that silently stopped running is otherwise invisible.
210
233
  emitTrace(TraceEvent.HOOK_RAN, {
211
- hook: "sanitize-user-prompt",
234
+ hook: HOOK_NAME,
212
235
  outcome:
213
236
  verdict.decision === controlPlane().Decision.DENY
214
237
  ? "deny"
@@ -222,24 +245,9 @@ export async function main(read, write, opts = {}) {
222
245
  readInput: read,
223
246
  write,
224
247
  onError: (err) =>
225
- write(
226
- JSON.stringify(
227
- failOpenEnabled(env)
228
- ? {
229
- hookSpecificOutput: {
230
- hookEventName: HookEvent.USER_PROMPT_SUBMIT,
231
- additionalContext: failOpenContext(
232
- "sanitize-user-prompt",
233
- "prompt",
234
- err,
235
- ),
236
- },
237
- }
238
- : {
239
- decision: "block",
240
- reason: messages.hookFailed(safeErrMessage(err)),
241
- },
242
- ),
248
+ writeFaultOutcome(
249
+ hookFaultOutcome(HOOK_NAME, err, { messages, env }),
250
+ write,
243
251
  ),
244
252
  },
245
253
  );
@@ -9,13 +9,20 @@ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
10
  import {
11
11
  awaitLazyDependency,
12
+ safeErrMessage,
12
13
  hookgateMarkerPath,
14
+ HookEvent,
13
15
  isMain,
14
16
  lazyImport,
15
17
  markerIsTrusted,
16
18
  probeSetupAlive,
17
19
  writeFileNoFollow,
18
20
  } from "./lib/hook-io.mjs";
21
+ import {
22
+ registerFaultPolicy,
23
+ hookFaultOutcome,
24
+ writeFaultOutcome,
25
+ } from "./lib/hook-fault.mjs";
19
26
  import {
20
27
  ALERT_FILE,
21
28
  ALERT_ACK_FILE,
@@ -77,6 +84,69 @@ async function ensureSanitizerLoaded() {
77
84
  /* c8 ignore stop */
78
85
  }
79
86
 
87
+ const HOOK_NAME = "scan-invisible-chars";
88
+
89
+ /**
90
+ * The stderr line both posture arms share: what broke, and what it cost.
91
+ * @param {{ message: string }} ctx
92
+ * @returns {string}
93
+ */
94
+ function faultLine(ctx) {
95
+ return (
96
+ `${HOOK_NAME}: ${ctx.message}. Instruction files were NOT fully scanned ` +
97
+ "for hidden Unicode, so any payload in them reaches the model unvetted."
98
+ );
99
+ }
100
+
101
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). It has no
102
+ // stdout verdict channel — SessionStart cannot deny — so BOTH arms are stated
103
+ // explicitly rather than taking the shared additionalContext default: the only
104
+ // enforcement a SessionStart hook can reach is the cross-hook alert, which makes
105
+ // the PreToolUse gate ask once on the next tool call.
106
+ registerFaultPolicy(HOOK_NAME, {
107
+ event: HookEvent.SESSION_START,
108
+ guarded: "instruction files",
109
+ open: (ctx) => ({
110
+ stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
111
+ exitCode: 1,
112
+ }),
113
+ closed: (ctx) => ({
114
+ stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
115
+ exitCode: 1,
116
+ armAlert: true,
117
+ }),
118
+ });
119
+
120
+ /**
121
+ * Render this hook's fault under the declared posture, and return the text (if
122
+ * any) that must ride in the cross-hook alert so a later gate carries the
123
+ * posture this hook cannot express itself.
124
+ * @param {unknown} err
125
+ * @returns {string[]}
126
+ */
127
+ function reportFault(err) {
128
+ const outcome = hookFaultOutcome(HOOK_NAME, err);
129
+ process.exitCode = writeFaultOutcome(outcome);
130
+ return outcome.armAlert ? [/** @type {string} */ (outcome.stderr)] : [];
131
+ }
132
+
133
+ /**
134
+ * Persist the accumulated alert text for the PreToolUse gate, or leave the alert
135
+ * absent when there is nothing to surface.
136
+ *
137
+ * ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
138
+ * writeFileSync would follow a co-tenant-planted symlink and overwrite an
139
+ * arbitrary file this uid owns. Create it symlink-refusingly (see
140
+ * writeFileNoFollow); the gate treats an absent alert as "nothing to surface",
141
+ * so a lost race degrades safely rather than to a hijacked write.
142
+ * @param {string[]} parts
143
+ * @returns {void}
144
+ */
145
+ function persistAlert(parts) {
146
+ if (parts.length === 0) return;
147
+ writeFileNoFollow(ALERT_FILE, parts.join("\n") + "\n");
148
+ }
149
+
80
150
  // Decoder
81
151
 
82
152
  /**
@@ -249,50 +319,118 @@ export { formatReport };
249
319
 
250
320
  // Main (skip when imported for testing)
251
321
 
252
- // Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
253
- // which in-process tests can't observe, so every mutant here is unkillable by
254
- // construction (same boundary as the c8-ignored regions below). The exported
255
- // scanFile/decodeRun above carry the real, mutation-tested logic.
256
322
  /**
257
- * Scan every instruction file under the project for invisible-char findings.
258
- * @returns {Array<{file: string, findings: ReturnType<typeof scanFile>}>}
323
+ * Scan every instruction file under the project, ACCOUNTING for every target
324
+ * the finder returned: `scanned + skipped.length === targets.length`, always.
325
+ *
326
+ * The accounting is the point. This scan is the only thing standing between a
327
+ * poisoned `CLAUDE.md` and a session that loads it as instructions, and its
328
+ * caller announces "clean" on the trace channel — the channel that exists so a
329
+ * MISSING announcement is loud. A per-file failure swallowed into an empty
330
+ * findings list turns "we could not read this file" into "this file is fine",
331
+ * which is the one lie this hook must never tell. So a file that cannot be read
332
+ * is REPORTED as unscanned, not dropped.
333
+ *
334
+ * ANY errno is a skip; only a non-filesystem throw propagates. The split is
335
+ * between "this file could not be read" (report it and keep scanning) and "this
336
+ * code is broken" (a TypeError from an unloaded binding — nothing here can be
337
+ * trusted, so it goes to the caller's declared failure posture). Catching only
338
+ * ENOENT would invert the enforcement: one EACCES target would discard the
339
+ * result for EVERY other instruction file, leaving them unscanned and
340
+ * un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
341
+ * nothing — so the SUSPICIOUS failure would get weaker enforcement than the
342
+ * benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
343
+ * split {@link autoCleanFindings} uses.
344
+ * @param {string} [dir] project root to scan (injectable for tests)
345
+ * @returns {{
346
+ * targets: string[],
347
+ * scanned: number,
348
+ * findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
349
+ * skipped: Array<{file: string, reason: string}>,
350
+ * }}
259
351
  */
260
- function scanProject() {
352
+ export function scanProject(dir = PROJECT_DIR) {
261
353
  const targets = [
262
354
  ...new Set([
263
- ...findInstructionFiles(PROJECT_DIR),
264
- ...findMdFiles(join(PROJECT_DIR, ".claude")),
355
+ ...findInstructionFiles(dir),
356
+ ...findMdFiles(join(dir, ".claude")),
265
357
  ]),
266
358
  ];
267
- const allFindings = [];
359
+ const findings = [];
360
+ const skipped = [];
361
+ let scanned = 0;
268
362
  for (const file of targets) {
363
+ let fileFindings;
269
364
  try {
270
- const findings = scanFile(file);
271
- if (findings.length > 0) {
272
- allFindings.push({ file: relative(PROJECT_DIR, file), findings });
273
- }
274
- } catch {
275
- // File doesn't exist or unreadable
365
+ fileFindings = scanFile(file);
366
+ } catch (err) {
367
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
368
+ throw err;
369
+ // safeErrMessage, not errMessage: this reason is rendered into stderr and
370
+ // into ALERT_FILE, and an errno message embeds the absolute path globbed
371
+ // out of a possibly-hostile repo — a filename carrying ANSI or invisible
372
+ // bytes would otherwise reach the operator's terminal raw.
373
+ skipped.push({ file: relative(dir, file), reason: safeErrMessage(err) });
374
+ continue;
276
375
  }
376
+ scanned++;
377
+ if (fileFindings.length > 0)
378
+ findings.push({ file: relative(dir, file), findings: fileFindings });
277
379
  }
278
- return allFindings;
380
+ return { targets, scanned, findings, skipped };
279
381
  }
280
382
 
383
+ /**
384
+ * The report for targets the scan could not read. Rendered into the alert the
385
+ * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
386
+ * checkpoint rather than as silence.
387
+ * @param {Array<{file: string, reason: string}>} skipped
388
+ * @returns {string}
389
+ */
390
+ export function formatSkipped(skipped) {
391
+ return [
392
+ "",
393
+ "━━━ INSTRUCTION FILES NOT SCANNED ━━━",
394
+ "",
395
+ "These files load as project instructions but could NOT be read, so they",
396
+ "were never checked for hidden Unicode. Treat their content as unvetted.",
397
+ "",
398
+ ...skipped.map(({ file, reason }) => ` ${file}: ${reason}`),
399
+ "",
400
+ ].join("\n");
401
+ }
402
+
403
+ // Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
404
+ // which in-process tests can't observe, so every mutant here is unkillable by
405
+ // construction (same boundary as the c8-ignored regions below). The exported
406
+ // scanFile / decodeRun / scanProject above carry the real, tested logic.
281
407
  /**
282
408
  * The hook's CLI: scan the instruction files, auto-clean what it can, persist
283
409
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
284
410
  * (which must claim the CLI slot before this module loads) can run the exact
285
411
  * same scan instead of duplicating it.
286
- * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
287
- * this scan announces engagement; a host with its own trace channel passes its
288
- * sink so the announcement lands where its detector reads (see lib/trace.mjs).
412
+ * @param {{
413
+ * trace?: import("./lib/trace.mjs").TraceFn,
414
+ * scan?: () => ReturnType<typeof scanProject>,
415
+ * }} [opts] `trace` is where this scan announces engagement; a host with its
416
+ * own trace channel passes its sink so the announcement lands where its
417
+ * detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
418
+ * FAULT path below — a scanner that throws something other than an errno, i.e.
419
+ * a bug — is drivable end to end; no filesystem state can force it, and an
420
+ * untested fault path is how a posture goes missing in the first place.
289
421
  * @returns {Promise<void>}
290
422
  */
291
- export async function cliMain({ trace: sink = trace } = {}) {
423
+ export async function cliMain({ trace: sink = trace, scan: runScan } = {}) {
292
424
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
293
425
  // the alert write, with no catch above them, so a throwing host sink would
294
426
  // abort the scan silently (see bestEffortTrace).
295
427
  const emitTrace = bestEffortTrace(sink);
428
+ // Everything the PreToolUse gate must surface this session, written once at
429
+ // the end: an incomplete scan and an uncleanable file are independent reasons
430
+ // to arm the gate, and two separate writes would have the second clobber the
431
+ // first.
432
+ /** @type {string[]} */
433
+ const alertParts = [];
296
434
  /* c8 ignore start -- fail-closed module-load guard: only reachable when the
297
435
  agent-sanitizer import above failed, which can't be simulated in the
298
436
  spawned-subprocess CLI run the tests observe. */
@@ -301,11 +439,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
301
439
  // the trace channel — a scan that never ran is otherwise invisible, and the
302
440
  // downstream PreToolUse sanitize gate then passes cleanly all session.
303
441
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
304
- process.stderr.write(
305
- "scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
306
- "installed and session-setup did not finish in time); instruction " +
307
- "files were NOT scanned for hidden Unicode. Run `pnpm install`.\n",
442
+ // Through the shared posture table, NOT a bare exit(1): this hook took an
443
+ // advisory posture unconditionally, so an operator who pinned
444
+ // AGENT_SANITIZER_FAIL_OPEN=0 got only a warning on the one hook guarding
445
+ // session-start ingress, and the PreToolUse gate then passed cleanly for
446
+ // the rest of the session. The closed arm arms the cross-hook alert, which
447
+ // is the only channel a SessionStart hook has to make a later gate ask.
448
+ alertParts.push(
449
+ ...reportFault(
450
+ new Error(
451
+ "agent-sanitizer failed to load (node deps not installed and " +
452
+ "session-setup did not finish in time); run `pnpm install`",
453
+ ),
454
+ ),
308
455
  );
456
+ persistAlert(alertParts);
309
457
  process.exit(1);
310
458
  }
311
459
  /* c8 ignore stop */
@@ -320,22 +468,61 @@ export async function cliMain({ trace: sink = trace } = {}) {
320
468
  }
321
469
  }
322
470
 
323
- const allFindings = scanProject();
471
+ // Only a non-errno throw reaches here — a bug in the scanner, not a file it
472
+ // could not read (those are accounted for in `skipped`). It is a fault of THIS
473
+ // hook, so it renders through the same posture table as every other hook's
474
+ // fault instead of aborting with no announcement at all.
475
+ let scan;
476
+ try {
477
+ scan = (runScan ?? scanProject)();
478
+ } catch (err) {
479
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
480
+ alertParts.push(...reportFault(err));
481
+ persistAlert(alertParts);
482
+ return;
483
+ }
484
+ const { findings: allFindings, skipped, scanned } = scan;
324
485
 
325
- if (allFindings.length === 0) {
486
+ // "clean" is a claim about EVERY target, so it may only be made when every
487
+ // target was read. A scan that could not read one says "partial" and arms the
488
+ // gate: an unread instruction file is UNVETTED context, not absent findings.
489
+ if (skipped.length > 0) {
490
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
491
+ outcome: "partial",
492
+ scanned,
493
+ skipped: skipped.length,
494
+ files: allFindings.length,
495
+ });
496
+ const notice = formatSkipped(skipped);
497
+ process.stderr.write(notice + "\n");
498
+ alertParts.push(notice);
499
+ } else if (allFindings.length === 0) {
326
500
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
327
501
  return;
502
+ } else {
503
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
504
+ outcome: "found",
505
+ files: allFindings.length,
506
+ });
328
507
  }
329
- emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
330
- outcome: "found",
331
- files: allFindings.length,
332
- });
333
508
 
334
- // Auto-clean contaminated files so the session proceeds without blocking
335
- // every tool call; the gate hook is the fallback when cleaning fails.
509
+ if (allFindings.length > 0)
510
+ alertParts.push(...autoCleanFindings(allFindings, PROJECT_DIR));
511
+ persistAlert(alertParts);
512
+ }
513
+
514
+ /**
515
+ * Auto-clean the contaminated files so the session proceeds without blocking
516
+ * every tool call, and return the alert text for whatever could not be cleaned
517
+ * (empty when everything was). The gate hook is the fallback for the rest.
518
+ * @param {Array<{file: string, findings: ReturnType<typeof scanFile>}>} allFindings
519
+ * @param {string} dir the root the finding paths are relative to
520
+ * @returns {string[]}
521
+ */
522
+ function autoCleanFindings(allFindings, dir) {
336
523
  let cleaned = 0;
337
524
  for (const { file } of allFindings) {
338
- const absPath = join(PROJECT_DIR, file);
525
+ const absPath = join(dir, file);
339
526
  try {
340
527
  const original = readFileSync(absPath, "utf-8");
341
528
  const stripped = stripInvisible(original);
@@ -344,15 +531,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
344
531
  cleaned++;
345
532
  }
346
533
  /* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
347
- } catch {
348
- // Unreadable or unwritable: the file stays contaminated and falls into the
349
- // alert path below, which hands it to the PreToolUse gate.
534
+ } catch (err) {
535
+ // Narrowed to filesystem errnos, and the reason is REPORTED rather than
536
+ // swallowed: an unwritable file legitimately falls through to the alert
537
+ // path below, but a throw from stripInvisible is a bug in the sanitizer
538
+ // and must not be laundered into "this file resisted cleaning".
539
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
540
+ throw err;
541
+ process.stderr.write(
542
+ `scan-invisible-chars: could not clean ${file}: ${safeErrMessage(err)}\n`,
543
+ );
350
544
  }
351
545
  /* c8 ignore stop */
352
546
  }
353
547
 
354
548
  const report = formatReport(allFindings);
355
-
356
549
  if (cleaned === allFindings.length) {
357
550
  process.stderr.write(
358
551
  report +
@@ -363,16 +556,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
363
556
  "suspicion, and restart the session if in doubt. Future sessions load " +
364
557
  "the cleaned files.\n",
365
558
  );
366
- /* c8 ignore start -- only reachable when the write catch above fires */
367
- } else {
368
- process.stderr.write(report + "\n");
369
- // ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
370
- // writeFileSync would follow a co-tenant-planted symlink and overwrite an
371
- // arbitrary file this uid owns. Create it symlink-refusingly (see
372
- // writeFileNoFollow); the PreToolUse gate treats an absent alert as "nothing
373
- // to surface", so a lost race degrades safely rather than to a hijacked write.
374
- writeFileNoFollow(ALERT_FILE, report + "\n");
559
+ return [];
375
560
  }
561
+ /* c8 ignore start -- only reachable when the write catch above fires */
562
+ process.stderr.write(report + "\n");
563
+ return [report];
376
564
  /* c8 ignore stop */
377
565
  }
378
566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.19.3",
3
+ "version": "2.19.4",
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": {