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.
@@ -41,6 +41,7 @@ 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,
@@ -68,7 +69,13 @@ import {
68
69
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
69
70
  import { secretsEnabled } from "./lib/env-config.mjs";
70
71
  import { readKnob } from "./lib/knobs.mjs";
71
- 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";
72
79
  import {
73
80
  containsPlaceholder,
74
81
  placeholderNotice,
@@ -81,6 +88,20 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
81
88
 
82
89
  const HOOK_NAME = "pretooluse-sanitize";
83
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
+
84
105
  /**
85
106
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
86
107
  * must be blocked, or null to let the pipeline continue. Hosts use these for
@@ -104,6 +125,8 @@ const HOOK_NAME = "pretooluse-sanitize";
104
125
  * }>}
105
126
  */
106
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.
107
130
  unknownEvent:
108
131
  "PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
109
132
  failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
@@ -188,12 +211,13 @@ const guardedRehydrate = withSecretDropGuard(
188
211
  /**
189
212
  * Substitute every keyed Layer-2 placeholder in `text` with the stored original
190
213
  * from the reveal store's span files, in ONE ordered pass (spliceOrdered).
191
- * A key with NO stored span fails CLOSED — the placeholder stands for real
192
- * content this store cannot produce, so writing it through literally would
193
- * 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.
194
218
  * @param {string} text
195
219
  * @param {string} field the tool-input field being rehydrated, for the deny prose
196
- * @returns {{ text: string, restored: number } | { deny: string } | null}
220
+ * @returns {{ text: string, restored: number } | { deny: string, fault: string } | null}
197
221
  */
198
222
  function substituteLayer2(text, field) {
199
223
  const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
@@ -211,16 +235,22 @@ function substituteLayer2(text, field) {
211
235
  if (stored === null) missing.push(key);
212
236
  else byKey.set(key, stored);
213
237
  }
214
- if (missing.length > 0)
238
+ if (missing.length > 0) {
239
+ const files = missing.map((key) => spanPath(key)).join(", ");
215
240
  return {
216
241
  deny:
217
242
  `${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
218
243
  `in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
219
- `${missing.map((key) => spanPath(key)).join(", ")}), so the removed content ` +
244
+ `${files}), so the removed content ` +
220
245
  `cannot be restored automatically. Reconstruct that content yourself, ` +
221
246
  `deliberately drop the placeholder(s) if the removed content should stay ` +
222
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`,
223
252
  };
253
+ }
224
254
  // `i` is the match's index in `matches` (stable across spliceOrdered's
225
255
  // overlap skips — impossible here, distinct non-overlapping literals), so the
226
256
  // key rides positionally.
@@ -263,7 +293,7 @@ function substituteLayer2(text, field) {
263
293
  * @param {boolean} [secretsOn] the run's secret opt-in (decided once by
264
294
  * buildPreToolUseResponse; a direct caller reads the env); only with it on
265
295
  * does the context claim the restored bytes were redacted
266
- * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
296
+ * @returns {{ updatedInput: any, context: string } | { deny: string, fault?: string } | null}
267
297
  */
268
298
  export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
269
299
  const hasL2 = (/** @type {unknown} */ text) =>
@@ -337,6 +367,52 @@ function emitTraced(emitTrace, toolName, fields) {
337
367
  return fields;
338
368
  }
339
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
+
340
416
  /**
341
417
  * The declared stage chain after the gate. Every entry states the two properties
342
418
  * the driver reasons about — whether it ERASES code points another layer reads,
@@ -353,13 +429,33 @@ function emitTraced(emitTrace, toolName, fields) {
353
429
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
354
430
  * @param {boolean} [secretsOn] the run's secret opt-in (see
355
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
356
435
  * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
357
436
  */
358
437
  export function preToolUseLayers(
359
438
  rehydrate,
360
439
  env = process.env,
361
440
  secretsOn = secretsEnabled(env),
441
+ ledger,
362
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
+ };
363
459
  /** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
364
460
  const layers = [
365
461
  {
@@ -410,7 +506,8 @@ export function preToolUseLayers(
410
506
  rehydrate
411
507
  )(tool, toolInput);
412
508
  if (!rehydrated) return null;
413
- if ("deny" in rehydrated) return { deny: rehydrated.deny };
509
+ if ("deny" in rehydrated)
510
+ return resolveDeny(tool, toolInput, rehydrated, "rehydrate");
414
511
  return {
415
512
  updatedInput: rehydrated.updatedInput,
416
513
  context: rehydrated.context,
@@ -426,7 +523,12 @@ export function preToolUseLayers(
426
523
  // legitimately contain [REDACTED…] text — must not be re-fed to the
427
524
  // secret resolver or re-stripped by an earlier layer.
428
525
  terminal: true,
429
- 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
+ },
430
532
  },
431
533
  ];
432
534
  return layers.filter(
@@ -473,13 +575,13 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
473
575
  // cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
474
576
  // degrades to a passive reminder, so it doesn't prompt on every tool call.
475
577
  const findings = invisibleCharAlert(input.session_id);
476
- let pendingGateAck = false;
578
+ /** @type {string | null} */
579
+ let gateAsk = null;
477
580
  if (findings) {
478
581
  if (alertAcknowledged(input.session_id)) {
479
582
  contexts.push(gateReminderContext());
480
583
  } else {
481
- asks.push(gateAskReason(findings));
482
- pendingGateAck = true;
584
+ gateAsk = gateAskReason(findings);
483
585
  }
484
586
  }
485
587
 
@@ -496,6 +598,8 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
496
598
  // The rewriting stages, run by the declared pipeline: the driver — not this
497
599
  // call order — is what keeps the confusable fold's soundness precondition
498
600
  // true once an erasing stage follows it (see lib/layer-pipeline.mjs).
601
+ /** @type {FaultLedger} */
602
+ const ledger = { warnings: [], released: [] };
499
603
  const {
500
604
  updatedInput: current,
501
605
  changed,
@@ -510,6 +614,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
510
614
  rehydrate ?? (secretsOn ? guardedRehydrate : null),
511
615
  process.env,
512
616
  secretsOn,
617
+ ledger,
513
618
  ),
514
619
  );
515
620
  if (deny !== undefined)
@@ -517,7 +622,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
517
622
  permissionDecision: PermissionDecision.DENY,
518
623
  permissionDecisionReason: deny,
519
624
  });
520
- contexts.push(...layerContexts);
625
+ contexts.push(...layerContexts, ...ledger.warnings);
521
626
  // A body that never settled: the pipeline hands back its LAST pass's sanitized
522
627
  // input plus the reason, and the verdict is this hook's registered fault
523
628
  // posture — never a verdict decided here. Open proceeds with `current` (the
@@ -528,11 +633,16 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
528
633
  const faultFields = hookFailureFields(true, new Error(fault), {
529
634
  input: { tool_name: tool, tool_input: current },
530
635
  });
636
+ if (faultFields.permissionDecision === PermissionDecision.DENY)
637
+ return emitTraced(emitTrace, input.tool_name, faultFields);
531
638
  if (faultFields.permissionDecisionReason !== undefined)
532
639
  asks.push(String(faultFields.permissionDecisionReason));
533
640
  if (faultFields.additionalContext !== undefined)
534
641
  contexts.push(String(faultFields.additionalContext));
535
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);
536
646
 
537
647
  // Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
538
648
  // anything unknown): rehydration cannot re-anchor these, so a placeholder in
@@ -558,20 +668,46 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
558
668
  current,
559
669
  asks,
560
670
  contexts,
561
- pendingGateAck,
671
+ gateAsk,
562
672
  pendingGapNotice: gapNotice !== null,
563
673
  sessionId: input.session_id,
564
674
  }),
565
675
  );
566
676
  }
567
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
+
568
703
  /**
569
704
  * Assemble the hookSpecificOutput fields from the per-layer results, or null
570
705
  * for a clean no-op (nothing asked, changed, or annotated). Records the gate
571
706
  * acknowledgement and the coverage-gap notice only when they actually land in
572
- * the response.
707
+ * the response — a deny above returns first, so a preempted ask is not marked
708
+ * seen.
573
709
  * @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
574
- * pendingGateAck: boolean, pendingGapNotice: boolean, sessionId?: string }} parts
710
+ * gateAsk: string | null, pendingGapNotice: boolean, sessionId?: string }} parts
575
711
  * @returns {Record<string, unknown> | null}
576
712
  */
577
713
  function assembleResponse({
@@ -579,10 +715,15 @@ function assembleResponse({
579
715
  current,
580
716
  asks,
581
717
  contexts,
582
- pendingGateAck,
718
+ gateAsk,
583
719
  pendingGapNotice,
584
720
  sessionId,
585
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
+ }
586
727
  if (asks.length === 0 && !changed && contexts.length === 0) return null;
587
728
 
588
729
  /** @type {Record<string, unknown>} */
@@ -596,9 +737,6 @@ function assembleResponse({
596
737
  fields.permissionDecisionReason = asks.join("\n\n");
597
738
  }
598
739
  if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
599
- // Record the gate ack only now that the ask is actually in the response — a
600
- // rehydrate deny above returns first, so a preempted ask is not marked seen.
601
- if (pendingGateAck) acknowledgeAlert(sessionId);
602
740
  if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
603
741
  return fields;
604
742
  }
@@ -608,9 +746,11 @@ function assembleResponse({
608
746
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
609
747
  * sanitization pipeline through its own adapter. The wired Claude CLI below
610
748
  * routes through this judge and renders the Verdict with the Claude adapter; on
611
- * any throw (a control-plane package-load failure included) it falls back to
612
- * hookFailureFields — a native response that needs no package — so the
613
- * 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.
614
754
  * @param {import("agent-control-plane-core").ToolCallEvent} event
615
755
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
616
756
  * @param {{
@@ -636,13 +776,18 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
636
776
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
637
777
  const { Decision, EventKind } = controlPlane();
638
778
  // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
639
- // would drive the pipeline with an empty event and no-op to ALLOW — a silent
640
- // fail-OPEN of the gate. This hook is wired only to PreToolUse, so an
641
- // unclassifiable payload is harness-contract drift or an out-of-band caller,
642
- // never a real call: deny-when-blind. Rewarding an unclassifiable payload with
643
- // 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.
644
784
  if (event.event === EventKind.UNKNOWN)
645
- 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
+ );
646
791
  // The session identity and the permission mode travel in `meta`, not alongside
647
792
  // the tool input. A gate keyed on the session (a once-per-session checkpoint)
648
793
  // cannot tell two sessions apart without the first, and a gate keyed on the
@@ -769,8 +914,8 @@ export const REDACTION_HINT = "[REDACTED";
769
914
 
770
915
  /**
771
916
  * True when a faulting PreToolUse call is the one case the OPEN posture must
772
- * still not pass: a write-shaped tool whose input carries a redaction
773
- * 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
774
919
  * hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
775
920
  * placeholder stands for a secret the sanitizer redacted out of the model's
776
921
  * view; with the sanitizer down, rehydration cannot translate it back, so
@@ -781,7 +926,7 @@ export const REDACTION_HINT = "[REDACTED";
781
926
  *
782
927
  * Deliberately NOT exhaustive over every clobber path: Bash can also write a
783
928
  * placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
784
- * 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
785
930
  * there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
786
931
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
787
932
  * @returns {boolean}
@@ -804,8 +949,9 @@ export function hintedWriteFault(input) {
804
949
  * fail-CLOSED verdict of {@link failClosedFields} when the caller set
805
950
  * AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
806
951
  * this hook's fault policy below: a write-shaped call whose input carries a
807
- * `[REDACTED…]` placeholder asks instead of passing (see
808
- * {@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.
809
955
  *
810
956
  * The posture covers this hook's own failures, whatever their cause. What it
811
957
  * does NOT cover is the verdict of a sanitizer that ran: a payload
@@ -832,19 +978,71 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
832
978
  );
833
979
  }
834
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
+
835
1024
  // This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
836
- // keeps the shared default — a warning context and no verdict — with ONE
837
- // carve-out: a write-shaped input carrying a [REDACTED… placeholder asks
838
- // instead. The open posture trades enforcement for availability on the
839
- // sanitizer's own breakage, and that trade is sound for a missed SCAN; a
840
- // placeholder-bearing write is not a scan but a guaranteed clobber — the
841
- // placeholder would be persisted literally over the secret it stands for (see
842
- // hintedWriteFault). The ask keeps a human in the loop exactly as the closed
843
- // 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.
844
1030
  registerFaultPolicy(HOOK_NAME, {
845
1031
  event: HookEvent.PRE_TOOL_USE,
846
1032
  guarded: "tool input",
847
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);
848
1046
  // Non-carve-out faults take the SHARED open rendering, not a copy of it —
849
1047
  // hook-fault.mjs owns that body, and a restated one would silently drift.
850
1048
  // The carve-out itself rides the secret opt-in: with secrets off no
@@ -852,32 +1050,60 @@ registerFaultPolicy(HOOK_NAME, {
852
1050
  // in a write is literal prose and holding it would be a false positive.
853
1051
  if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
854
1052
  return defaultOpen(ctx);
855
- // parsedOk is hardcoded true (the ASK arm): hintedWriteFault(undefined)
856
- // is false, so an unparsed input can never reach this line — reaching it
857
- // 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.
858
1056
  const closed = failClosedFields(true, ctx.err, {
859
1057
  messages: ctx.messages,
860
1058
  hint: ctx.hint,
861
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
+ };
862
1100
  return {
863
- fields: {
864
- ...closed,
865
- permissionDecisionReason:
866
- `${closed.permissionDecisionReason} This input would write ` +
867
- `${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
868
- `secret the unavailable sanitizer cannot translate back; proceeding ` +
869
- `would overwrite the real secret with the placeholder, so the call ` +
870
- `is held even under the fail-open posture. Retry once the sanitizer ` +
871
- `recovers, or ask the user to make this change.`,
872
- },
1101
+ fields: failClosedFields(ctx.parsedOk, ctx.err, {
1102
+ messages: ctx.messages,
1103
+ hint: ctx.hint,
1104
+ }),
873
1105
  };
874
1106
  },
875
- closed: (ctx) => ({
876
- fields: failClosedFields(ctx.parsedOk, ctx.err, {
877
- messages: ctx.messages,
878
- hint: ctx.hint,
879
- }),
880
- }),
881
1107
  });
882
1108
 
883
1109
  // Stryker disable all: CLI wiring — it runs only in the spawned hook
@@ -77,6 +77,9 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
77
77
 
78
78
  const HOOK_NAME = "sanitize-user-prompt";
79
79
 
80
+ /** The `code` of the error a payload the adapter cannot classify raises. */
81
+ const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
82
+
80
83
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
81
84
  // shared default (a warning context alongside the prompt); CLOSED is a
82
85
  // top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
@@ -85,15 +88,18 @@ const HOOK_NAME = "sanitize-user-prompt";
85
88
  registerFaultPolicy(HOOK_NAME, {
86
89
  event: HookEvent.USER_PROMPT_SUBMIT,
87
90
  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
- }),
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
+ },
97
103
  });
98
104
 
99
105
  /* c8 ignore start — module-load boundary: the imports resolve in every real
@@ -121,8 +127,9 @@ registerFaultPolicy(HOOK_NAME, {
121
127
  * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
122
128
  * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
123
129
  * renders through any agent adapter, not just Claude's. Throws (into the
124
- * calling hook's catch) when the sanitizer package never loaded, so a prompt it
125
- * 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.
126
133
  * @param {import("agent-control-plane-core").ToolCallEvent} event
127
134
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
128
135
  * to the package's stripAnsiFully; injectable so the fault path is testable)
@@ -142,11 +149,17 @@ export function judgeSanitizeUserPrompt(
142
149
  const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
143
150
  const { Decision, EventKind } = controlPlane();
144
151
  // A payload the adapter cannot classify carries no readable prompt, so an
145
- // abstain would fail OPEN on harness contract drift; this gate's posture is
146
- // deny-when-blind. (runJudgeCli's renderEscalated blocks it with exit 2 and
147
- // 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.
148
156
  if (event.event === EventKind.UNKNOWN)
149
- 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
+ );
150
163
  if (event.event !== EventKind.PROMPT_SUBMIT)
151
164
  return { decision: Decision.ALLOW };
152
165
  // The module-load guard, one arm per binding. The two lazyImports are