agent-sanitizer 2.68.0 → 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.
@@ -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,
@@ -157,36 +157,76 @@ function faultLine(ctx) {
157
157
  );
158
158
  }
159
159
 
160
- // This hook's entry in the one posture table (lib/hook-fault.mjs). It has no
161
- // stdout verdict channel — SessionStart cannot deny — so BOTH arms are stated
162
- // explicitly rather than taking the shared additionalContext default: the only
163
- // enforcement a SessionStart hook can reach is the cross-hook alert, which makes
164
- // 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.
165
192
  registerFaultPolicy(HOOK_NAME, {
166
193
  event: HookEvent.SESSION_START,
167
194
  guarded: "instruction files",
168
- open: (ctx) => ({
169
- stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
170
- exitCode: 1,
171
- }),
172
- closed: (ctx) => ({
173
- stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
174
- exitCode: 1,
175
- armAlert: true,
176
- }),
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 },
177
208
  });
178
209
 
179
210
  /**
180
- * Render this hook's fault under the declared posture, and return the text (if
181
- * any) that must ride in the cross-hook alert so a later gate carries the
182
- * 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.
183
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
184
219
  * @returns {string[]}
185
220
  */
186
- function reportFault(err) {
187
- const outcome = hookFaultOutcome(HOOK_NAME, err);
188
- process.exitCode = writeFaultOutcome(outcome);
189
- 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)];
190
230
  }
191
231
 
192
232
  /**
@@ -331,9 +371,9 @@ export function scanProject(dir = PROJECT_DIR) {
331
371
  }
332
372
 
333
373
  /**
334
- * The report for targets the scan could not read. Rendered into the alert the
335
- * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
336
- * 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.
337
377
  * @param {Array<{file: string, reason: string}>} skipped
338
378
  * @returns {string}
339
379
  */
@@ -383,12 +423,15 @@ export async function cliMain(opts = {}) {
383
423
  // reporting an overrun in band is what turned a 30-second scan from a rumor
384
424
  // into a bug report (see lib/hook-timing.mjs).
385
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();
386
429
  try {
387
430
  // Charged to provisioning when the session's setup is installing THROUGHOUT
388
431
  // this run: that install saturates the machine, and a scan that merely
389
432
  // waited it out has no per-call cost to report (see hook-timing.mjs).
390
433
  await excludeConcurrentProvisioning(
391
- () => runScanCli(opts),
434
+ () => runScanCli(opts, out),
392
435
  () => setupRunning(hookgateMarkerPath()),
393
436
  );
394
437
  } finally {
@@ -396,7 +439,7 @@ export async function cliMain(opts = {}) {
396
439
  HOOK_NAME,
397
440
  timer.wallMs(),
398
441
  HookEvent.SESSION_START,
399
- emitHookResponse,
442
+ out.emit,
400
443
  undefined,
401
444
  // All four windows, including the two this scan normally leaves empty: a
402
445
  // measured 0 rules a window OUT, where an omitted one leaves the notice
@@ -407,6 +450,7 @@ export async function cliMain(opts = {}) {
407
450
  hostMs: timer.hostMs(),
408
451
  },
409
452
  );
453
+ out.flush();
410
454
  }
411
455
  }
412
456
 
@@ -419,9 +463,10 @@ export async function cliMain(opts = {}) {
419
463
  * scan?: () => ReturnType<typeof scanProject>,
420
464
  * sessionId?: string,
421
465
  * }} opts see {@link cliMain}
466
+ * @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out see {@link cliMain}
422
467
  * @returns {Promise<void>}
423
468
  */
424
- async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
469
+ async function runScanCli({ trace: sink, scan: runScan, sessionId }, out) {
425
470
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
426
471
  // the alert write, with no catch above them, so a throwing host sink would
427
472
  // abort the scan silently (see hookTrace).
@@ -452,9 +497,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
452
497
  "agent-sanitizer failed to load (node deps not installed and " +
453
498
  "session-setup did not finish in time); run `pnpm install`",
454
499
  ),
500
+ undefined,
501
+ out,
455
502
  ),
456
503
  );
457
504
  persistAlert(alertParts, sessionId);
505
+ // process.exit skips cliMain's `finally`, which would otherwise flush.
506
+ out.flush();
458
507
  process.exit(1);
459
508
  }
460
509
  /* c8 ignore stop */
@@ -480,14 +529,14 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
480
529
  scan = (runScan ?? scanProject)();
481
530
  } catch (err) {
482
531
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
483
- alertParts.push(...reportFault(err));
532
+ alertParts.push(...reportFault(err, undefined, out));
484
533
  persistAlert(alertParts, sessionId);
485
534
  return;
486
535
  }
487
536
  const { findings: allFindings, skipped, absent, scanned } = scan;
488
537
 
489
538
  // The three buckets of classifyReadFailure, rendered: only `skipped` may
490
- // withhold "clean" and arm the gate.
539
+ // withhold "clean", and it arms the gate only under the closed posture.
491
540
  if (skipped.length > 0) {
492
541
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
493
542
  outcome: "partial",
@@ -496,9 +545,13 @@ async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
496
545
  absent: absent.length,
497
546
  files: allFindings.length,
498
547
  });
499
- const notice = formatSkipped(skipped);
500
- process.stderr.write(notice + "\n");
501
- 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
+ );
502
555
  } else if (allFindings.length === 0) {
503
556
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
504
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,
@@ -268,12 +268,15 @@ export { HOOK_NAME };
268
268
  export async function cliMain({ trace: sink } = {}) {
269
269
  const timer = startHookTimer();
270
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();
271
274
  try {
272
275
  // Charged to provisioning when the session's setup is installing THROUGHOUT
273
276
  // this run: that install saturates the machine, and a scan that merely
274
277
  // waited it out has no per-call cost to report (see hook-timing.mjs).
275
278
  await excludeConcurrentProvisioning(
276
- () => runLoadedScanCli(emitTrace),
279
+ () => runLoadedScanCli(emitTrace, out),
277
280
  () => setupRunning(hookgateMarkerPath()),
278
281
  );
279
282
  } finally {
@@ -281,7 +284,7 @@ export async function cliMain({ trace: sink } = {}) {
281
284
  HOOK_NAME,
282
285
  timer.wallMs(),
283
286
  HookEvent.INSTRUCTIONS_LOADED,
284
- emitHookResponse,
287
+ out.emit,
285
288
  undefined,
286
289
  // All four windows, including the two this scan normally leaves empty: a
287
290
  // measured 0 rules a window OUT, where an omitted one leaves the notice
@@ -292,6 +295,7 @@ export async function cliMain({ trace: sink } = {}) {
292
295
  hostMs: timer.hostMs(),
293
296
  },
294
297
  );
298
+ out.flush();
295
299
  }
296
300
  }
297
301
 
@@ -300,9 +304,11 @@ export async function cliMain({ trace: sink } = {}) {
300
304
  * single call to bracket — every early return here is an exit that wrapper must
301
305
  * still measure.
302
306
  * @param {import("./lib/trace.mjs").TraceFn} emitTrace
307
+ * @param {import("./lib/hook-io.mjs").EnvelopeBuffer} out this run's one stdout
308
+ * buffer
303
309
  * @returns {Promise<void>}
304
310
  */
305
- async function runLoadedScanCli(emitTrace) {
311
+ async function runLoadedScanCli(emitTrace, out) {
306
312
  /** @type {string | undefined} */
307
313
  let sessionId;
308
314
  try {
@@ -340,19 +346,19 @@ async function runLoadedScanCli(emitTrace) {
340
346
  // systemMessage reaches the user, additionalContext the model. Both, because
341
347
  // this hook cannot block and the file is already loaded: the user is the one
342
348
  // who can act on it, and the model is the one currently reading it.
343
- process.stdout.write(
344
- JSON.stringify({
345
- systemMessage: message,
346
- hookSpecificOutput: {
347
- hookEventName: HookEvent.INSTRUCTIONS_LOADED,
348
- additionalContext: message,
349
- },
350
- }),
351
- );
349
+ out.add({
350
+ systemMessage: message,
351
+ hookSpecificOutput: {
352
+ hookEventName: HookEvent.INSTRUCTIONS_LOADED,
353
+ additionalContext: message,
354
+ },
355
+ });
352
356
  } catch (err) {
353
357
  emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, { outcome: "skipped" });
354
358
  const outcome = hookFaultOutcome(HOOK_NAME, err);
355
- process.exitCode = writeFaultOutcome(outcome);
359
+ process.exitCode = writeFaultOutcome(outcome, (chunk) =>
360
+ out.add(JSON.parse(chunk)),
361
+ );
356
362
  // `sessionId`, captured before the throw: a payload read that itself failed
357
363
  // leaves it undefined, and the fault then lands in the shared fallback store
358
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.68.0",
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
package/src/output.mjs CHANGED
@@ -365,8 +365,9 @@ function processLayer1(text, sgrCarveOut) {
365
365
  * Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
366
366
  * whole document in ONE synchronous call, so nothing interrupts it, and a host
367
367
  * that kills the overrun hook shows the RAW text. Called before EACH parse, and
368
- * only after a pre-gate passed, so a declined call costs no time. Fails closed,
369
- * as Layer 4 does.
368
+ * only after a pre-gate passed, so a declined call costs no time. Throws, as
369
+ * Layer 4 does: whether the unchecked text then passes or is withheld is the
370
+ * caller's fault posture, never this layer's.
370
371
  * @param {Deadline | undefined} deadline
371
372
  * @returns {void}
372
373
  */
@@ -374,8 +375,8 @@ function refuseIfSpent(deadline) {
374
375
  if (deadline && deadline.remainingMs() <= 0)
375
376
  throw new Error(
376
377
  "CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
377
- "and exfil-URL layers finished, so this text was not fully checked. " +
378
- "Failing closed — tool output suppressed.",
378
+ "and exfil-URL layers finished, so this text was not fully checked " +
379
+ "and must be treated as UNSANITIZED.",
379
380
  );
380
381
  }
381
382
 
package/src/rehydrate.mjs CHANGED
@@ -823,6 +823,12 @@ function isCandidate(tool, ti) {
823
823
  * there is nothing to do. Throws only on internal error (the caller fails
824
824
  * closed).
825
825
  *
826
+ * A deny carrying `fault` is not a verdict on the input but this module being
827
+ * unable to do its job (an unreadable target, a redactor map that fails
828
+ * validation): `fault` states what proceeding un-rehydrated would write, and the
829
+ * caller's failure posture decides whether the deny stands. A deny without it
830
+ * is a verdict and stands in every posture.
831
+ *
826
832
  * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
827
833
  * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
828
834
  * only if the injected redactor emits a different placeholder shape.
@@ -830,7 +836,7 @@ function isCandidate(tool, ti) {
830
836
  * @param {any} toolInput
831
837
  * @param {RehydrateIo} io
832
838
  * @param {{ hint?: string }} [options]
833
- * @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
839
+ * @returns {Promise<{updatedInput: any, context: string} | {deny: string, fault?: string} | null>}
834
840
  */
835
841
  export async function rehydrateRedacted(
836
842
  tool,
@@ -911,12 +917,17 @@ export async function rehydrateRedacted(
911
917
  if (tool !== "Write") throw err;
912
918
  return null;
913
919
  }
920
+ const cause = nodeErr?.code ?? nodeErr?.message;
914
921
  return {
915
922
  deny:
916
923
  `could not read ${toolInput.file_path} to rehydrate its secrets ` +
917
- `(${nodeErr?.code ?? nodeErr?.message}); the file likely still exists, so writing ` +
924
+ `(${cause}); the file likely still exists, so writing ` +
918
925
  `the placeholder text as-is risks overwriting a real secret. Retry the read, or ` +
919
926
  `ask the user to make this change directly`,
927
+ fault:
928
+ `${toolInput.file_path} could not be read (${cause}) to rehydrate its ` +
929
+ `secrets, so this call is not rehydrated and its ${hint}…] placeholder ` +
930
+ `text is written to the file literally`,
920
931
  };
921
932
  }
922
933
  const { layer1Cleaned, cleaned } = layer1View(content);
@@ -949,8 +960,14 @@ export async function rehydrateRedacted(
949
960
  // reaches its own resolver-backed pass-through.
950
961
  if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
951
962
  if (!hinted) return null;
963
+ // A verdict, not a fault: the file itself defeats mapping on every call, so
964
+ // a released re-issue would overwrite the real secret with its placeholder.
965
+ // The escape is to stop writing the placeholder, which the reason names.
952
966
  return {
953
- deny: `cannot resolve redaction placeholders in ${toolInput.file_path}: ${mapped.unmappable}`,
967
+ deny:
968
+ `cannot resolve redaction placeholders in ${toolInput.file_path}: ` +
969
+ `${mapped.unmappable}. Leave every ${hint}…] placeholder out of this ` +
970
+ `call (edit around it), or ask the user to make this change`,
954
971
  };
955
972
  }
956
973
  // The redactor emits code-point offsets; the offset machinery below works in
@@ -970,9 +987,8 @@ export async function rehydrateRedacted(
970
987
  // before acting: construction re-checks range/ordering (the throws in
971
988
  // assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
972
989
  // reconstructs `cleaned` exactly. A defective map DENIES for every tool and
973
- // hint state, and is deliberately NOT allowed to throw out of this module: a
974
- // throw lands in the host's failure posture, whose shipped default is fail
975
- // OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
990
+ // hint state with a `fault`, never a throw: the deny lets the caller refuse
991
+ // the call once and name what proceeding writes, which a throw cannot.
976
992
  let view = null;
977
993
  let mapDefect = null;
978
994
  try {
@@ -993,14 +1009,25 @@ export async function rehydrateRedacted(
993
1009
  // Reaching this line at all means the fast pass-through did not fire — the
994
1010
  // file provably holds redacted content or diverges from its view — so an
995
1011
  // unhinted pass-through here would hand the real Edit bytes inside spans
996
- // nothing can vet (the R1 hidden-span oracle). Deny everything until the
997
- // redactor is fixed.
1012
+ // nothing can vet (the R1 hidden-span oracle). Deny, as a fault: the
1013
+ // engine, not the input, is what is wrong.
998
1014
  if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
1015
+ // A Write replaces the whole file, and the secret-drop guard reads this
1016
+ // same broken map, so the loss it would name has to be named here instead.
1017
+ const dropped =
1018
+ tool === "Write"
1019
+ ? `; it replaces the whole file, so any redacted secret it does not ` +
1020
+ `reproduce is removed, and which secrets those are cannot be checked`
1021
+ : "";
999
1022
  return {
1000
1023
  deny:
1001
1024
  `cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
1002
1025
  `redacted content whose location cannot be trusted, so no edit can be ` +
1003
- `verified. Retry later, or ask the user to make this change`,
1026
+ `verified${dropped}. Retry later, or ask the user to make this change`,
1027
+ fault:
1028
+ `${mapDefect} for ${toolInput.file_path}, so this call is not ` +
1029
+ `rehydrated: any ${hint}…] placeholder text in it is written to the ` +
1030
+ `file literally, and it is applied without the redacted-span checks${dropped}`,
1004
1031
  };
1005
1032
  }
1006
1033
  // View identical to disk: any placeholders in an Edit's old_string are
@@ -110,8 +110,10 @@ export type FaultContext = Record<string, any> & {
110
110
  /**
111
111
  * A hook's declared posture. There is deliberately no way for a policy to pin
112
112
  * the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
113
- * withhold a tool output or block a prompt unless the operator set the knob to
114
- * `0`. A pin turns any bug in the guarded layer into every tool call failing.
113
+ * withhold a tool output, block a prompt, or hold a tool call for good unless the
114
+ * operator set the knob to `0` (an open arm may refuse a call once, releasing the
115
+ * exact re-issue). A pin turns any bug in the guarded layer into every tool call
116
+ * failing.
115
117
  */
116
118
  export type FaultPolicy = {
117
119
  event: string | null;
@@ -286,6 +286,25 @@ export function safeErrMessage(err: unknown, cap?: number): string;
286
286
  * @returns {void}
287
287
  */
288
288
  export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
289
+ /**
290
+ * @typedef {{
291
+ * add: (envelope: Record<string, any>) => void,
292
+ * emit: (hookEventName: string, fields: Record<string, unknown>) => void,
293
+ * flush: () => void,
294
+ * }} EnvelopeBuffer
295
+ */
296
+ /**
297
+ * Collect everything a hook run wants to say on stdout and write it as exactly
298
+ * ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
299
+ * document, so two envelopes written by two reporters (a fault notice, then the
300
+ * slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
301
+ * output and the host DROPS the whole response — the model never sees either
302
+ * warning. Every reporter in a run therefore adds here instead of writing, and
303
+ * the entry point flushes once, last.
304
+ * @param {(chunk: string) => void} [write] injectable stdout sink, for tests
305
+ * @returns {EnvelopeBuffer}
306
+ */
307
+ export function createEnvelopeBuffer(write?: (chunk: string) => void): EnvelopeBuffer;
289
308
  /**
290
309
  * The directory the session-lifetime stores live under: the alert markers and
291
310
  * findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
@@ -522,14 +541,16 @@ export function touchLiveState(path: string): void;
522
541
  * file the hook's user owns. Unlink any existing entry first (removing a squatted
523
542
  * symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
524
543
  * window fails the open rather than being dereferenced. Content is irrelevant —
525
- * callers test only for existence — so the file is left empty. Best-effort: a
526
- * missing/read-only $TMPDIR or a lost race just leaves the sentinel absent, and
527
- * every caller treats "absent" as "not yet done" (a repeated ask, never a crash),
528
- * so all failures are swallowed.
544
+ * callers test only for existence — so the file is left empty. Never throws: a
545
+ * missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
546
+ * return value says so. A caller whose refusal is released only by a later read
547
+ * of this sentinel must branch on it, because a refusal that records nothing can
548
+ * never be released.
529
549
  * @param {string} path
530
- * @returns {void}
550
+ * @returns {boolean} whether a sentinel this uid owns now exists at `path`
551
+ * (the same test {@link markerIsTrusted} applies when a reader looks for it)
531
552
  */
532
- export function writeSentinelFile(path: string): void;
553
+ export function writeSentinelFile(path: string): boolean;
533
554
  /**
534
555
  * Write `content` to `path` without following a symlink a co-tenant may have
535
556
  * pre-planted there — the content-bearing counterpart to writeSentinelFile. These
@@ -635,6 +656,11 @@ export const STATE_ROOT: string;
635
656
  * says it locks may be judged by the lock.
636
657
  */
637
658
  export const SETUP_LOCK_DECLARATION: "flock";
659
+ export type EnvelopeBuffer = {
660
+ add: (envelope: Record<string, any>) => void;
661
+ emit: (hookEventName: string, fields: Record<string, unknown>) => void;
662
+ flush: () => void;
663
+ };
638
664
  /**
639
665
  * EVERY process-wide slot these helpers keep — the four a host can observe or
640
666
  * steer, so a second instance that adopts this object is steered in all four at
@@ -193,11 +193,14 @@ export function alertAcknowledged(sessionId?: string): boolean;
193
193
  /**
194
194
  * Record that the gate has surfaced its blocking ask, so later tool calls get a
195
195
  * passive reminder instead of an ask on every call. Session-keyed, so the next
196
- * session re-asks once without anything having to clear this.
196
+ * session re-asks once without anything having to clear this. Returns false
197
+ * when the ack could not be recorded, so the caller knows the next call will
198
+ * ask again: under the fail-open posture it surfaces the finding as a warning
199
+ * rather than asking on every tool call for the rest of the session.
197
200
  * @param {string} [sessionId]
198
- * @returns {void}
201
+ * @returns {boolean}
199
202
  */
200
- export function acknowledgeAlert(sessionId?: string): void;
203
+ export function acknowledgeAlert(sessionId?: string): boolean;
201
204
  /**
202
205
  * The blocking ask. The heading states only that the scan did not finish clean:
203
206
  * the alert carries injection findings, unreadable targets, or a scanner fault,