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
@@ -41,10 +41,10 @@ import {
41
41
  safeErrMessage,
42
42
  HookEvent,
43
43
  PermissionDecision,
44
+ FAIL_OPEN_ENV,
44
45
  } from "./lib/hook-io.mjs";
45
46
  import {
46
47
  registerFaultPolicy,
47
- KNOB_DECIDES,
48
48
  hookFaultOutcome,
49
49
  defaultOpen,
50
50
  } from "./lib/hook-fault.mjs";
@@ -69,7 +69,13 @@ import {
69
69
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
70
70
  import { secretsEnabled } from "./lib/env-config.mjs";
71
71
  import { readKnob } from "./lib/knobs.mjs";
72
- import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
72
+ import {
73
+ withSecretDropGuard,
74
+ reissueFingerprint,
75
+ recordConfirm,
76
+ confirmFresh,
77
+ consumeConfirm,
78
+ } from "./lib/secret-drop-guard.mjs";
73
79
  import {
74
80
  containsPlaceholder,
75
81
  placeholderNotice,
@@ -82,6 +88,20 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
82
88
 
83
89
  const HOOK_NAME = "pretooluse-sanitize";
84
90
 
91
+ /**
92
+ * The non-throw sites that route a sanitizer fault through this hook's fault
93
+ * policy, as `ctx.site`. A fault rendered by hookFailureFields (a throw the CLI
94
+ * caught, a pipeline that never settled) carries no site.
95
+ * - STAGE: a stage denied only because it could not do its job (its result
96
+ * carries `fault`); the closed arm keeps that deny.
97
+ * - GATE_ACK: the invisible-char gate's one-time ask could not be recorded as
98
+ * shown; the closed arm keeps the ask.
99
+ */
100
+ const FaultSite = Object.freeze({ STAGE: "stage", GATE_ACK: "gate-ack" });
101
+
102
+ /** The `code` of the error a payload the adapter cannot classify raises. */
103
+ const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
104
+
85
105
  /**
86
106
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
87
107
  * must be blocked, or null to let the pipeline continue. Hosts use these for
@@ -105,6 +125,8 @@ const HOOK_NAME = "pretooluse-sanitize";
105
125
  * }>}
106
126
  */
107
127
  export const PRE_TOOL_USE_MESSAGES = Object.freeze({
128
+ // The CLOSED posture's reason for a payload the adapter cannot classify; the
129
+ // open posture lets that payload through with the shared fault warning.
108
130
  unknownEvent:
109
131
  "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
110
132
  failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
@@ -189,12 +211,13 @@ const guardedRehydrate = withSecretDropGuard(
189
211
  /**
190
212
  * Substitute every keyed Layer-2 placeholder in `text` with the stored original
191
213
  * from the reveal store's span files, in ONE ordered pass (spliceOrdered).
192
- * A key with NO stored span fails CLOSED — the placeholder stands for real
193
- * content this store cannot produce, so writing it through literally would
194
- * silently persist the loss the keyed grammar exists to prevent.
214
+ * A key with NO stored span (never persisted, or the store is unusable) denies
215
+ * as a `fault` — the placeholder stands for real content this store cannot
216
+ * produce, so writing it through literally persists the loss the keyed grammar
217
+ * exists to prevent, and only the hook's fault posture may let that happen.
195
218
  * @param {string} text
196
219
  * @param {string} field the tool-input field being rehydrated, for the deny prose
197
- * @returns {{ text: string, restored: number } | { deny: string } | null}
220
+ * @returns {{ text: string, restored: number } | { deny: string, fault: string } | null}
198
221
  */
199
222
  function substituteLayer2(text, field) {
200
223
  const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
@@ -212,16 +235,22 @@ function substituteLayer2(text, field) {
212
235
  if (stored === null) missing.push(key);
213
236
  else byKey.set(key, stored);
214
237
  }
215
- if (missing.length > 0)
238
+ if (missing.length > 0) {
239
+ const files = missing.map((key) => spanPath(key)).join(", ");
216
240
  return {
217
241
  deny:
218
242
  `${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
219
243
  `in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
220
- `${missing.map((key) => spanPath(key)).join(", ")}), so the removed content ` +
244
+ `${files}), so the removed content ` +
221
245
  `cannot be restored automatically. Reconstruct that content yourself, ` +
222
246
  `deliberately drop the placeholder(s) if the removed content should stay ` +
223
247
  `removed, or ask the user to make this change`,
248
+ fault:
249
+ `the Layer-2 removed-content placeholder(s) with key(s) ${missing.join(", ")} ` +
250
+ `in ${field} have no stored original (expected file(s): ${files}), so ` +
251
+ `they are written to the file literally and the removed content is not restored`,
224
252
  };
253
+ }
225
254
  // `i` is the match's index in `matches` (stable across spliceOrdered's
226
255
  // overlap skips — impossible here, distinct non-overlapping literals), so the
227
256
  // key rides positionally.
@@ -264,7 +293,7 @@ function substituteLayer2(text, field) {
264
293
  * @param {boolean} [secretsOn] the run's secret opt-in (decided once by
265
294
  * buildPreToolUseResponse; a direct caller reads the env); only with it on
266
295
  * does the context claim the restored bytes were redacted
267
- * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
296
+ * @returns {{ updatedInput: any, context: string } | { deny: string, fault?: string } | null}
268
297
  */
269
298
  export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
270
299
  const hasL2 = (/** @type {unknown} */ text) =>
@@ -338,6 +367,52 @@ function emitTraced(emitTrace, toolName, fields) {
338
367
  return fields;
339
368
  }
340
369
 
370
+ /**
371
+ * What the stage table hands back besides the pipeline's own result: the
372
+ * warnings of fault denies the open posture let through (the pipeline drops a
373
+ * stage's note when the input is unchanged, and these calls proceed unchanged),
374
+ * and the re-issue confirmations they rode on, consumed only once the call is
375
+ * known to proceed.
376
+ * @typedef {{ warnings: string[], released: string[] }} FaultLedger
377
+ */
378
+
379
+ /**
380
+ * Resolve one stage's deny. A deny without `fault` is the stage's verdict on
381
+ * the input and stands in every posture. A deny WITH one exists only because
382
+ * the stage could not do its job, so this hook's registered fault policy
383
+ * decides it: it stands under the closed posture, and under the open one it is
384
+ * refused once with a re-issue escape, then let through with a warning —
385
+ * carrying the stage's `release` rewrite when it has one, else unchanged.
386
+ * @param {string} tool
387
+ * @param {any} toolInput
388
+ * @param {{ deny: string, fault?: string, release?: { updatedInput: any, context: string } }} result
389
+ * @param {string} site the refusing stage, keying its re-issue confirmation
390
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
391
+ * @param {FaultLedger} ledger
392
+ * @returns {{ deny: string } | { updatedInput: any, context: string } | null}
393
+ */
394
+ function resolveStageDeny(tool, toolInput, result, site, env, ledger) {
395
+ if (result.fault === undefined) return { deny: result.deny };
396
+ const fingerprint = reissueFingerprint(site, tool, toolInput);
397
+ const { fields } = hookFaultOutcome(HOOK_NAME, new Error(result.fault), {
398
+ env,
399
+ site: FaultSite.STAGE,
400
+ fingerprint,
401
+ reason: result.deny,
402
+ warning:
403
+ `WARNING: agent-sanitizer could not complete its ${site} stage on this ` +
404
+ `${tool} call and let it proceed ${result.release ? "rehydrated" : "unchanged"} ` +
405
+ `(fail-open): ${result.fault}. ` +
406
+ `Set ${FAIL_OPEN_ENV}=0 to refuse such calls instead.`,
407
+ });
408
+ const verdict = /** @type {Record<string, unknown>} */ (fields);
409
+ if (verdict.permissionDecision === PermissionDecision.DENY)
410
+ return { deny: String(verdict.permissionDecisionReason) };
411
+ ledger.warnings.push(String(verdict.additionalContext));
412
+ ledger.released.push(fingerprint);
413
+ return result.release ?? null;
414
+ }
415
+
341
416
  /**
342
417
  * The declared stage chain after the gate. Every entry states the two properties
343
418
  * the driver reasons about — whether it ERASES code points another layer reads,
@@ -354,13 +429,33 @@ function emitTraced(emitTrace, toolName, fields) {
354
429
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
355
430
  * @param {boolean} [secretsOn] the run's secret opt-in (see
356
431
  * buildPreToolUseResponse, which decides it once and passes it here)
432
+ * @param {FaultLedger} [ledger] where a fault deny the open posture lets
433
+ * through leaves its warning; required as soon as a stage reports a fault,
434
+ * since a warning with nowhere to go would let the call through silently
357
435
  * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
358
436
  */
359
437
  export function preToolUseLayers(
360
438
  rehydrate,
361
439
  env = process.env,
362
440
  secretsOn = secretsEnabled(env),
441
+ ledger,
363
442
  ) {
443
+ /** @type {(tool: string, toolInput: any, result: { deny: string, fault?: string, release?: { updatedInput: any, context: string } }, site: string) => ReturnType<typeof resolveStageDeny>} */
444
+ const resolveDeny = (tool, toolInput, result, site) => {
445
+ if (result.fault !== undefined && ledger === undefined)
446
+ throw new Error(
447
+ `preToolUseLayers: the ${site} stage reported a fault but no ledger ` +
448
+ "was passed to carry its warning",
449
+ );
450
+ return resolveStageDeny(
451
+ tool,
452
+ toolInput,
453
+ result,
454
+ site,
455
+ env,
456
+ /** @type {FaultLedger} */ (ledger),
457
+ );
458
+ };
364
459
  /** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
365
460
  const layers = [
366
461
  {
@@ -411,7 +506,8 @@ export function preToolUseLayers(
411
506
  rehydrate
412
507
  )(tool, toolInput);
413
508
  if (!rehydrated) return null;
414
- if ("deny" in rehydrated) return { deny: rehydrated.deny };
509
+ if ("deny" in rehydrated)
510
+ return resolveDeny(tool, toolInput, rehydrated, "rehydrate");
415
511
  return {
416
512
  updatedInput: rehydrated.updatedInput,
417
513
  context: rehydrated.context,
@@ -427,7 +523,12 @@ export function preToolUseLayers(
427
523
  // legitimately contain [REDACTED…] text — must not be re-fed to the
428
524
  // secret resolver or re-stripped by an earlier layer.
429
525
  terminal: true,
430
- run: (tool, toolInput) => rehydrateLayer2(tool, toolInput, secretsOn),
526
+ run: (tool, toolInput) => {
527
+ const result = rehydrateLayer2(tool, toolInput, secretsOn);
528
+ return result !== null && "deny" in result
529
+ ? resolveDeny(tool, toolInput, result, "layer2-rehydrate")
530
+ : result;
531
+ },
431
532
  },
432
533
  ];
433
534
  return layers.filter(
@@ -474,13 +575,13 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
474
575
  // cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
475
576
  // degrades to a passive reminder, so it doesn't prompt on every tool call.
476
577
  const findings = invisibleCharAlert(input.session_id);
477
- let pendingGateAck = false;
578
+ /** @type {string | null} */
579
+ let gateAsk = null;
478
580
  if (findings) {
479
581
  if (alertAcknowledged(input.session_id)) {
480
582
  contexts.push(gateReminderContext());
481
583
  } else {
482
- asks.push(gateAskReason(findings));
483
- pendingGateAck = true;
584
+ gateAsk = gateAskReason(findings);
484
585
  }
485
586
  }
486
587
 
@@ -497,6 +598,8 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
497
598
  // The rewriting stages, run by the declared pipeline: the driver — not this
498
599
  // call order — is what keeps the confusable fold's soundness precondition
499
600
  // true once an erasing stage follows it (see lib/layer-pipeline.mjs).
601
+ /** @type {FaultLedger} */
602
+ const ledger = { warnings: [], released: [] };
500
603
  const {
501
604
  updatedInput: current,
502
605
  changed,
@@ -511,6 +614,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
511
614
  rehydrate ?? (secretsOn ? guardedRehydrate : null),
512
615
  process.env,
513
616
  secretsOn,
617
+ ledger,
514
618
  ),
515
619
  );
516
620
  if (deny !== undefined)
@@ -518,7 +622,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
518
622
  permissionDecision: PermissionDecision.DENY,
519
623
  permissionDecisionReason: deny,
520
624
  });
521
- contexts.push(...layerContexts);
625
+ contexts.push(...layerContexts, ...ledger.warnings);
522
626
  // A body that never settled: the pipeline hands back its LAST pass's sanitized
523
627
  // input plus the reason, and the verdict is this hook's registered fault
524
628
  // posture — never a verdict decided here. Open proceeds with `current` (the
@@ -529,11 +633,16 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
529
633
  const faultFields = hookFailureFields(true, new Error(fault), {
530
634
  input: { tool_name: tool, tool_input: current },
531
635
  });
636
+ if (faultFields.permissionDecision === PermissionDecision.DENY)
637
+ return emitTraced(emitTrace, input.tool_name, faultFields);
532
638
  if (faultFields.permissionDecisionReason !== undefined)
533
639
  asks.push(String(faultFields.permissionDecisionReason));
534
640
  if (faultFields.additionalContext !== undefined)
535
641
  contexts.push(String(faultFields.additionalContext));
536
642
  }
643
+ // The call proceeds past every refusal, so the confirmations it rode on are
644
+ // spent: each approves exactly one re-issue.
645
+ for (const fingerprint of ledger.released) consumeConfirm(fingerprint);
537
646
 
538
647
  // Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
539
648
  // anything unknown): rehydration cannot re-anchor these, so a placeholder in
@@ -559,20 +668,46 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
559
668
  current,
560
669
  asks,
561
670
  contexts,
562
- pendingGateAck,
671
+ gateAsk,
563
672
  pendingGapNotice: gapNotice !== null,
564
673
  sessionId: input.session_id,
565
674
  }),
566
675
  );
567
676
  }
568
677
 
678
+ /**
679
+ * Record the gate's acknowledgement and render its one-time ask. When the ack
680
+ * cannot be recorded, the next call would ask again, and the one after — so the
681
+ * fault posture decides: closed keeps the ask, open carries the findings as a
682
+ * warning instead of prompting on every tool call for the rest of the session.
683
+ * @param {string} gateAsk
684
+ * @param {string} [sessionId]
685
+ * @returns {{ ask: string } | { context: string }}
686
+ */
687
+ function gateVerdict(gateAsk, sessionId) {
688
+ if (acknowledgeAlert(sessionId)) return { ask: gateAsk };
689
+ const fields = /** @type {Record<string, unknown>} */ (
690
+ hookFaultOutcome(
691
+ HOOK_NAME,
692
+ new Error(
693
+ "the invisible-char gate's acknowledgement could not be recorded",
694
+ ),
695
+ { site: FaultSite.GATE_ACK, gateAsk },
696
+ ).fields
697
+ );
698
+ return fields.permissionDecision === PermissionDecision.ASK
699
+ ? { ask: String(fields.permissionDecisionReason) }
700
+ : { context: String(fields.additionalContext) };
701
+ }
702
+
569
703
  /**
570
704
  * Assemble the hookSpecificOutput fields from the per-layer results, or null
571
705
  * for a clean no-op (nothing asked, changed, or annotated). Records the gate
572
706
  * acknowledgement and the coverage-gap notice only when they actually land in
573
- * the response.
707
+ * the response — a deny above returns first, so a preempted ask is not marked
708
+ * seen.
574
709
  * @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
575
- * pendingGateAck: boolean, pendingGapNotice: boolean, sessionId?: string }} parts
710
+ * gateAsk: string | null, pendingGapNotice: boolean, sessionId?: string }} parts
576
711
  * @returns {Record<string, unknown> | null}
577
712
  */
578
713
  function assembleResponse({
@@ -580,10 +715,15 @@ function assembleResponse({
580
715
  current,
581
716
  asks,
582
717
  contexts,
583
- pendingGateAck,
718
+ gateAsk,
584
719
  pendingGapNotice,
585
720
  sessionId,
586
721
  }) {
722
+ if (gateAsk !== null) {
723
+ const gate = gateVerdict(gateAsk, sessionId);
724
+ if ("ask" in gate) asks.unshift(gate.ask);
725
+ else contexts.unshift(gate.context);
726
+ }
587
727
  if (asks.length === 0 && !changed && contexts.length === 0) return null;
588
728
 
589
729
  /** @type {Record<string, unknown>} */
@@ -597,9 +737,6 @@ function assembleResponse({
597
737
  fields.permissionDecisionReason = asks.join("\n\n");
598
738
  }
599
739
  if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
600
- // Record the gate ack only now that the ask is actually in the response — a
601
- // rehydrate deny above returns first, so a preempted ask is not marked seen.
602
- if (pendingGateAck) acknowledgeAlert(sessionId);
603
740
  if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
604
741
  return fields;
605
742
  }
@@ -609,9 +746,11 @@ function assembleResponse({
609
746
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
610
747
  * sanitization pipeline through its own adapter. The wired Claude CLI below
611
748
  * routes through this judge and renders the Verdict with the Claude adapter; on
612
- * any throw (a control-plane package-load failure included) it falls back to
613
- * hookFailureFields — a native response that needs no package — so the
614
- * caller's fault posture holds even when the adapter never loaded.
749
+ * any throw (a control-plane package-load failure, or an event the adapter
750
+ * could not classify) it falls back to hookFailureFields — a native response
751
+ * that needs no package — so the caller's fault posture holds even when the
752
+ * adapter never loaded. A host calling this directly renders its throws the
753
+ * same way.
615
754
  * @param {import("agent-control-plane-core").ToolCallEvent} event
616
755
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
617
756
  * @param {{
@@ -637,13 +776,18 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
637
776
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
638
777
  const { Decision, EventKind } = controlPlane();
639
778
  // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
640
- // would drive the pipeline with an empty event and no-op to ALLOW — a silent
641
- // fail-OPEN of the gate. This hook is wired only to PreToolUse, so an
642
- // unclassifiable payload is harness-contract drift or an out-of-band caller,
643
- // never a real call: deny-when-blind. Rewarding an unclassifiable payload with
644
- // a pass is the one incentive a gate must never create.
779
+ // would drive the pipeline with an empty event and no-op to a CLEAN allow,
780
+ // reporting a sanitized call nobody vetted. This hook is wired only to
781
+ // PreToolUse, so it is harness-contract drift: a fault, thrown so the fault
782
+ // policy decides — a warning by default, a deny carrying the error's `reason`
783
+ // (this host's `messages.unknownEvent`) under AGENT_SANITIZER_FAIL_OPEN=0.
645
784
  if (event.event === EventKind.UNKNOWN)
646
- return { decision: Decision.DENY, reason: messages.unknownEvent };
785
+ throw Object.assign(
786
+ new Error(
787
+ "pretooluse-sanitize: unrecognized hook payload (not PreToolUse)",
788
+ ),
789
+ { code: UNKNOWN_EVENT_CODE, reason: messages.unknownEvent },
790
+ );
647
791
  // The session identity and the permission mode travel in `meta`, not alongside
648
792
  // the tool input. A gate keyed on the session (a once-per-session checkpoint)
649
793
  // cannot tell two sessions apart without the first, and a gate keyed on the
@@ -770,8 +914,8 @@ export const REDACTION_HINT = "[REDACTED";
770
914
 
771
915
  /**
772
916
  * True when a faulting PreToolUse call is the one case the OPEN posture must
773
- * still not pass: a write-shaped tool whose input carries a redaction
774
- * placeholder, matched by the real grammar (containsPlaceholder) so
917
+ * not pass on the first try: a write-shaped tool whose input carries a
918
+ * redaction placeholder, matched by the real grammar (containsPlaceholder) so
775
919
  * hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
776
920
  * placeholder stands for a secret the sanitizer redacted out of the model's
777
921
  * view; with the sanitizer down, rehydration cannot translate it back, so
@@ -782,7 +926,7 @@ export const REDACTION_HINT = "[REDACTED";
782
926
  *
783
927
  * Deliberately NOT exhaustive over every clobber path: Bash can also write a
784
928
  * placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
785
- * mention "[REDACTED" benignly far too often for an ask to hold precision
929
+ * mention "[REDACTED" benignly far too often for a refusal to hold precision
786
930
  * there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
787
931
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
788
932
  * @returns {boolean}
@@ -805,8 +949,9 @@ export function hintedWriteFault(input) {
805
949
  * fail-CLOSED verdict of {@link failClosedFields} when the caller set
806
950
  * AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
807
951
  * this hook's fault policy below: a write-shaped call whose input carries a
808
- * `[REDACTED…]` placeholder asks instead of passing (see
809
- * {@link hintedWriteFault}) — pass `input` so the policy can see it.
952
+ * `[REDACTED…]` placeholder is refused once, and its exact re-issue proceeds
953
+ * with a warning (see {@link hintedWriteFault}) — pass `input` so the policy
954
+ * can see it.
810
955
  *
811
956
  * The posture covers this hook's own failures, whatever their cause. What it
812
957
  * does NOT cover is the verdict of a sanitizer that ran: a payload
@@ -833,20 +978,71 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
833
978
  );
834
979
  }
835
980
 
981
+ /** Appended to a releasable refusal: the escape that keeps it from looping. */
982
+ const REISSUE_NOTE =
983
+ "If writing this text as-is is intended, re-issue this exact call (same " +
984
+ "tool, same input) to confirm: the re-issue proceeds and writes it literally.";
985
+
986
+ /**
987
+ * The open posture's answer to a fault that would clobber real file content.
988
+ * A refusal the model cannot get past is a stuck session, so the refusal is
989
+ * issued only once it has a release: the first call is refused naming the
990
+ * re-issue escape, the confirmed re-issue proceeds with `warning`, and when the
991
+ * confirmation cannot be recorded at all the call proceeds with `warning` on
992
+ * the first try, since nothing could ever release that refusal.
993
+ * @param {string} fingerprint the refusal's {@link reissueFingerprint}
994
+ * @param {boolean} consume spend the confirmation now; false for a caller that
995
+ * learns later whether the call proceeds and consumes it then
996
+ * @param {string} reason the refusal, without the re-issue note
997
+ * @param {string} warning what proceeding writes, for the model
998
+ * @returns {import("./lib/hook-fault.mjs").FaultParts}
999
+ */
1000
+ function releasableRefusal(fingerprint, consume, reason, warning) {
1001
+ if (consume ? consumeConfirm(fingerprint) : confirmFresh(fingerprint))
1002
+ return {
1003
+ fields: {
1004
+ additionalContext: `${warning} It proceeded as the confirmed re-issue of a call refused for this reason.`,
1005
+ },
1006
+ };
1007
+ if (!recordConfirm(fingerprint))
1008
+ return {
1009
+ fields: {
1010
+ additionalContext:
1011
+ `${warning} It proceeded without a refusal: the re-issue ` +
1012
+ `confirmation could not be recorded, so a refusal could never be released.`,
1013
+ },
1014
+ };
1015
+ const sentence = /[.!?]$/u.test(reason) ? reason : `${reason}.`;
1016
+ return {
1017
+ fields: {
1018
+ permissionDecision: PermissionDecision.DENY,
1019
+ permissionDecisionReason: `${sentence} ${REISSUE_NOTE}`,
1020
+ },
1021
+ };
1022
+ }
1023
+
836
1024
  // This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
837
- // keeps the shared default — a warning context and no verdict — with ONE
838
- // carve-out: a write-shaped input carrying a [REDACTED… placeholder asks
839
- // instead. The open posture trades enforcement for availability on the
840
- // sanitizer's own breakage, and that trade is sound for a missed SCAN; a
841
- // placeholder-bearing write is not a scan but a guaranteed clobber — the
842
- // placeholder would be persisted literally over the secret it stands for (see
843
- // hintedWriteFault). The ask keeps a human in the loop exactly as the closed
844
- // posture's clean-parse arm does.
1025
+ // keeps the shared default — a warning context and no verdict — except where
1026
+ // proceeding would clobber real content: a write-shaped input carrying a
1027
+ // [REDACTED… placeholder on a hook fault (see hintedWriteFault), and a STAGE
1028
+ // fault deny. Those take {@link releasableRefusal}. The two `site` arms are the
1029
+ // non-throw faults {@link FaultSite} lists.
845
1030
  registerFaultPolicy(HOOK_NAME, {
846
1031
  event: HookEvent.PRE_TOOL_USE,
847
1032
  guarded: "tool input",
848
- alwaysClosed: KNOB_DECIDES,
849
1033
  open: (ctx) => {
1034
+ if (ctx.site === FaultSite.GATE_ACK)
1035
+ return {
1036
+ fields: {
1037
+ additionalContext:
1038
+ "WARNING: agent-sanitizer could not record that its instruction-file " +
1039
+ "alert was shown this session, so it is reported here rather than as " +
1040
+ `a permission prompt on every tool call (fail-open; set ${FAIL_OPEN_ENV}=0 ` +
1041
+ `to be asked instead).\n\n${ctx.gateAsk}`,
1042
+ },
1043
+ };
1044
+ if (ctx.site === FaultSite.STAGE)
1045
+ return releasableRefusal(ctx.fingerprint, false, ctx.reason, ctx.warning);
850
1046
  // Non-carve-out faults take the SHARED open rendering, not a copy of it —
851
1047
  // hook-fault.mjs owns that body, and a restated one would silently drift.
852
1048
  // The carve-out itself rides the secret opt-in: with secrets off no
@@ -854,32 +1050,60 @@ registerFaultPolicy(HOOK_NAME, {
854
1050
  // in a write is literal prose and holding it would be a false positive.
855
1051
  if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
856
1052
  return defaultOpen(ctx);
857
- // parsedOk is hardcoded true (the ASK arm): hintedWriteFault(undefined)
858
- // is false, so an unparsed input can never reach this line — reaching it
859
- // proves the payload parsed.
1053
+ // parsedOk is hardcoded true: hintedWriteFault(undefined) is false, so an
1054
+ // unparsed input can never reach this line. This is the final decision on
1055
+ // the call, so the confirmation is spent here.
860
1056
  const closed = failClosedFields(true, ctx.err, {
861
1057
  messages: ctx.messages,
862
1058
  hint: ctx.hint,
863
1059
  });
1060
+ return releasableRefusal(
1061
+ reissueFingerprint(
1062
+ "hook-fault",
1063
+ ctx.input.tool_name,
1064
+ ctx.input.tool_input,
1065
+ ),
1066
+ true,
1067
+ `${closed.permissionDecisionReason} This input would write ` +
1068
+ `${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
1069
+ `secret the unavailable sanitizer cannot translate back; proceeding ` +
1070
+ `overwrites the real secret with the placeholder.`,
1071
+ `${ctx.openContext} This input carries ${REDACTION_HINT}…] placeholder ` +
1072
+ `text, which is written literally over the real secret it stands for.`,
1073
+ );
1074
+ },
1075
+ closed: (ctx) => {
1076
+ if (ctx.site === FaultSite.GATE_ACK)
1077
+ return {
1078
+ fields: {
1079
+ permissionDecision: PermissionDecision.ASK,
1080
+ permissionDecisionReason: ctx.gateAsk,
1081
+ },
1082
+ };
1083
+ if (ctx.site === FaultSite.STAGE)
1084
+ return {
1085
+ fields: {
1086
+ permissionDecision: PermissionDecision.DENY,
1087
+ permissionDecisionReason: ctx.reason,
1088
+ },
1089
+ };
1090
+ const err = /** @type {{ code?: unknown, reason?: unknown } | null} */ (
1091
+ ctx.err
1092
+ );
1093
+ if (err?.code === UNKNOWN_EVENT_CODE)
1094
+ return {
1095
+ fields: {
1096
+ permissionDecision: PermissionDecision.DENY,
1097
+ permissionDecisionReason: err.reason,
1098
+ },
1099
+ };
864
1100
  return {
865
- fields: {
866
- ...closed,
867
- permissionDecisionReason:
868
- `${closed.permissionDecisionReason} This input would write ` +
869
- `${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
870
- `secret the unavailable sanitizer cannot translate back; proceeding ` +
871
- `would overwrite the real secret with the placeholder, so the call ` +
872
- `is held even under the fail-open posture. Retry once the sanitizer ` +
873
- `recovers, or ask the user to make this change.`,
874
- },
1101
+ fields: failClosedFields(ctx.parsedOk, ctx.err, {
1102
+ messages: ctx.messages,
1103
+ hint: ctx.hint,
1104
+ }),
875
1105
  };
876
1106
  },
877
- closed: (ctx) => ({
878
- fields: failClosedFields(ctx.parsedOk, ctx.err, {
879
- messages: ctx.messages,
880
- hint: ctx.hint,
881
- }),
882
- }),
883
1107
  });
884
1108
 
885
1109
  // Stryker disable all: CLI wiring — it runs only in the spawned hook