agent-sanitizer 2.60.0 → 2.60.2

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.
@@ -22,10 +22,12 @@
22
22
  * {@link CONFIRM_TTL_MS}, so one confirmation approves exactly one prompt
23
23
  * Write — never a stale or standing auto-approval.
24
24
  *
25
- * Fail-open posture (per the package's precision-over-recall doctrine): a
25
+ * Skip-on-ambiguity (per the package's precision-over-recall doctrine): a
26
26
  * failed git probe, an unmappable redaction view, or a secret-free file all
27
27
  * skip the guard — a false denial here blocks legitimate work, while a false
28
- * pass costs only what today's behavior already allows.
28
+ * pass costs only what the call would do without this guard. This is a
29
+ * guard-local precision choice, not the hook fault posture: an unexpected
30
+ * throw still propagates to the hook's registered policy (lib/hook-fault.mjs).
29
31
  */
30
32
  import { createHash } from "node:crypto";
31
33
  import { lstatSync, readdirSync, unlinkSync } from "node:fs";
@@ -111,22 +113,38 @@ export function sweepStaleConfirms() {
111
113
  }
112
114
 
113
115
  /**
114
- * Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
115
- * (not a repository) both mean "no git recovery exists", which is what the
116
- * guard actually cares about. A spawn-level failure (no git binary) means we
117
- * cannot tell — report tracked so the guard skips (fail open) rather than
118
- * denying on missing tooling.
116
+ * Whether git tracks `filePath`, with every probe failure on the skip side the
117
+ * module doc declares. Exit 0 is tracked and exit 1 untracked. Exit 128 is
118
+ * either "not a repository" — no git recovery exists, so the guard applies — or
119
+ * a repository git could not read (a corrupt index, a permission error), which
120
+ * says nothing about the file; a second probe asks git which, reading its
121
+ * printed answer (`false` inside a bare repository or a `.git` directory is
122
+ * also exit 0, and also has no recovery path). A spawn-level
123
+ * failure (no git binary) or any other status also means we cannot tell —
124
+ * report tracked so the guard skips rather than denying on broken tooling.
119
125
  * @param {string} filePath
120
126
  * @param {typeof spawnSync} [spawn]
121
127
  * @returns {boolean}
122
128
  */
123
129
  export function gitTracked(filePath, spawn = spawnSync) {
130
+ const cwd = dirname(filePath);
124
131
  const res = spawn("git", ["ls-files", "--error-unmatch", "--", filePath], {
125
- cwd: dirname(filePath),
132
+ cwd,
126
133
  stdio: "ignore",
127
134
  });
128
135
  if (res.error) return true;
129
- return res.status === 0;
136
+ if (res.status === 0 || res.status === 1) return res.status === 0;
137
+ if (res.status !== 128) return true;
138
+ const repo = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ stdio: ["ignore", "pipe", "ignore"],
142
+ });
143
+ if (repo.error) return true;
144
+ // Exit 0 does not mean "in a work tree": inside a bare repository or a
145
+ // repository's own `.git` directory it prints `false`. Such a path has no
146
+ // work-tree/index recovery, so only the printed `true` skips the guard.
147
+ return repo.status === 0 && String(repo.stdout).trim() === "true";
130
148
  }
131
149
 
132
150
  /**
@@ -1,31 +1,31 @@
1
1
  /**
2
- * PreToolUse content-protection orchestrator. Runs five layers in ONE process:
2
+ * PreToolUse content-protection orchestrator. Runs five stages in ONE process
3
+ * (named, not numbered — the public "Layer N" numbering is a different axis):
3
4
  *
4
- * 1. Invisible-char injection gate (lib/invisible-alert.mjs)
5
- * 2. Confusable/homoglyph normalization of paths & commands
6
- * (agent-sanitizer/confusables, namespace-guard scanner injected)
7
- * 3. Stego / terminal-control stripping of model-authored fields
8
- * (lib/authored-content.mjs)
9
- * 4. Rehydration of secret-redaction placeholders in Edit/Write inputs
10
- * (agent-sanitizer/rehydrate, redactor-daemon io injected)
11
- * 5. Rehydration of keyed Layer-2 splice placeholders in Edit/Write inputs
12
- * ({@link rehydrateLayer2}, span store in lib/reveal.mjs) — disjoint
13
- * grammar from step 4, run after it
5
+ * - gate: invisible-char injection gate (lib/invisible-alert.mjs)
6
+ * - confusables: homoglyph normalization of paths & commands
7
+ * (agent-sanitizer/confusables, namespace-guard scanner injected)
8
+ * - authored-content: stego / terminal-control stripping of model-authored
9
+ * fields (lib/authored-content.mjs)
10
+ * - rehydrate: secret-redaction (Layer-4) placeholders in Edit/Write inputs
11
+ * (agent-sanitizer/rehydrate, redactor-daemon io injected)
12
+ * - layer2-rehydrate: keyed Layer-2 splice placeholders in Edit/Write inputs
13
+ * ({@link rehydrateLayer2}, span store in lib/reveal.mjs) — a grammar
14
+ * disjoint from the secret one, run after it
14
15
  *
15
16
  * WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
16
17
  * chain their `updatedInput` — each hook sees the original input and the last to
17
- * finish wins. Registered as three separate hooks, layers 2 and 3 both rewrite
18
- * the shared Bash `command` field from the original text, so a command carrying
19
- * BOTH a confusable AND a stego payload had one fix non-deterministically
20
- * clobbered by the other. Composing them here makes the rewrite deterministic
21
- * (normalize, then strip the normalized text) and pays a single Node start
22
- * instead of three on the hottest path. Layers 2-4 run through the declared
23
- * pipeline in lib/layer-pipeline.mjs, which is what keeps the confusable fold's
24
- * skip decisions sound once the erasing strip follows it.
18
+ * finish wins. As separate hooks, the confusable and authored-content stages
19
+ * both rewrite the Bash `command` field from the original text, so a command
20
+ * carrying both payloads would have one fix clobbered by the other. Composing
21
+ * them here makes the rewrite deterministic and pays one Node start instead of
22
+ * three on the hottest path. Every stage after the gate runs through the
23
+ * declared pipeline in lib/layer-pipeline.mjs, which keeps the confusable
24
+ * fold's skip decisions sound once the erasing strip follows it.
25
25
  *
26
- * Layers 2 and 4 are the provider-agnostic transforms in the agent-sanitizer
27
- * package; this file binds its peers (namespace-guard, the redactor daemon, the
28
- * filesystem) into them.
26
+ * The confusables and rehydrate stages are provider-agnostic transforms in the
27
+ * agent-sanitizer package; this file binds its peers (namespace-guard, the
28
+ * redactor daemon, the filesystem) into them.
29
29
  */
30
30
  import { createRequire } from "node:module";
31
31
  import { readFileSync } from "node:fs";
@@ -69,6 +69,7 @@ import { secretsEnabled } from "./lib/env-config.mjs";
69
69
  import { readKnob } from "./lib/knobs.mjs";
70
70
  import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
71
71
  import {
72
+ containsPlaceholder,
72
73
  placeholderNotice,
73
74
  layer2PlaceholderNotice,
74
75
  layer2Keys,
@@ -113,10 +114,11 @@ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
113
114
  remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
114
115
  });
115
116
 
116
- // Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
117
- // its doc for the fail-OPEN hazard of a bare static npm import); a failed load
118
- // leaves these bindings undefined, so the layer calls below throw into the CLI's
119
- // fail-closed catch (ask) instead.
117
+ // The confusables and rehydrate stages come from the agent-sanitizer package,
118
+ // bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
119
+ // npm import); a failed load leaves these bindings undefined, so the stage calls
120
+ // below throw into the CLI's catch, which applies this hook's fault posture
121
+ // (hookFailureFields).
120
122
  const { normalizeConfusables, normalizeContext } =
121
123
  /** @type {typeof import("agent-sanitizer/confusables")} */ (
122
124
  await lazyImport("agent-sanitizer/confusables")
@@ -235,11 +237,12 @@ function substituteLayer2(text, field) {
235
237
  * removed #<key>]` placeholders (copied from sanitized tool output) has each
236
238
  * one restored to the stored original bytes from the reveal store's span files.
237
239
  *
238
- * SECURITY invariant: the stored span content was REDACTED before persistence
239
- * (sanitize-output runs strict web-ingress redaction on each splice original
240
- * before persistSpan), so this rehydration can never write a raw secret — the
241
- * worst it can restore is `[REDACTED…]` placeholder text standing where the
242
- * secret was, which the on-disk tripwire then flags on later Reads.
240
+ * SECURITY invariant, with the secret layer on: the stored span content was
241
+ * REDACTED before persistence (sanitize-output runs strict web-ingress
242
+ * redaction on each splice original before persistSpan), so this rehydration
243
+ * can never write a raw secret — the worst it can restore is `[REDACTED…]`
244
+ * placeholder text, which the on-disk tripwire then flags on later Reads. With
245
+ * the layer off, spans are stored verbatim and the context claims nothing.
243
246
  *
244
247
  * Composition with the secret rehydrator: this runs AFTER it (a later terminal
245
248
  * layer), and the two grammars are disjoint — a Layer-2 placeholder never
@@ -256,9 +259,12 @@ function substituteLayer2(text, field) {
256
259
  * edits / notebook JSON cannot be rehydrated).
257
260
  * @param {string} tool
258
261
  * @param {any} toolInput
262
+ * @param {boolean} [secretsOn] the run's secret opt-in (decided once by
263
+ * buildPreToolUseResponse; a direct caller reads the env); only with it on
264
+ * does the context claim the restored bytes were redacted
259
265
  * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
260
266
  */
261
- export function rehydrateLayer2(tool, toolInput) {
267
+ export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
262
268
  const hasL2 = (/** @type {unknown} */ text) =>
263
269
  typeof text === "string" && layer2Keys(text).length > 0;
264
270
  if (
@@ -301,24 +307,13 @@ export function rehydrateLayer2(tool, toolInput) {
301
307
  updatedInput: { ...toolInput, [field]: result.text },
302
308
  context:
303
309
  `${result.restored} Layer-2 removed-content placeholder(s) in ${field} ` +
304
- `were restored to the stored original content (secrets inside were ` +
305
- `redacted before storage, so no raw secret is written).`,
310
+ `were restored to the stored original content` +
311
+ (secretsOn
312
+ ? ` (secrets inside were redacted before storage, so no raw secret is written).`
313
+ : "."),
306
314
  };
307
315
  }
308
316
 
309
- /**
310
- * The wired default gates the whole rehydration layer on the secret opt-in:
311
- * with secrets off the output hook never inserts placeholders, so there is
312
- * nothing to re-anchor — and skipping here (rather than inside the layer)
313
- * means an Edit on a plain file never touches the file system twice or spawns
314
- * the daemon. Consulted per call, not at module load, so a knob set after the
315
- * bundle loads still governs the next tool call.
316
- * @param {string} tool
317
- * @param {any} toolInput
318
- */
319
- const defaultRehydrate = async (tool, toolInput) =>
320
- secretsEnabled() ? guardedRehydrate(tool, toolInput) : null;
321
-
322
317
  /**
323
318
  * Trace the response on the way out — "noop" (clean pass-through), "deny",
324
319
  * "ask", or "modified" (input rewritten and/or context attached) — and return
@@ -342,21 +337,28 @@ function emitTraced(emitTrace, toolName, fields) {
342
337
  }
343
338
 
344
339
  /**
345
- * The declared layer chain, layers 2-4. Every entry states the two properties
340
+ * The declared stage chain after the gate. Every entry states the two properties
346
341
  * the driver reasons about — whether it ERASES code points another layer reads,
347
342
  * and whether its own decision is SKIP-BASED and therefore invalidated by such
348
343
  * an erasure — so the confusable fold's ordering precondition is enforced by the
349
344
  * table instead of restated as a comment.
350
345
  *
351
- * Layer 3 is dropped from the table (not merely skipped at run time) when
346
+ * The authored-content stage is dropped from the table (not merely skipped) when
352
347
  * AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
353
348
  * actually run: with no erasing layer left after the fold there is no fixed
354
- * point to reach and no extra pass to pay for.
355
- * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
349
+ * point to reach and no extra pass to pay for. A null `rehydrate` drops the
350
+ * secret rehydrator the same way.
351
+ * @param {((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null} rehydrate
356
352
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
353
+ * @param {boolean} [secretsOn] the run's secret opt-in (see
354
+ * buildPreToolUseResponse, which decides it once and passes it here)
357
355
  * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
358
356
  */
359
- export function preToolUseLayers(rehydrate, env = process.env) {
357
+ export function preToolUseLayers(
358
+ rehydrate,
359
+ env = process.env,
360
+ secretsOn = secretsEnabled(env),
361
+ ) {
360
362
  /** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
361
363
  const layers = [
362
364
  {
@@ -400,10 +402,12 @@ export function preToolUseLayers(rehydrate, env = process.env) {
400
402
  skipBased: false,
401
403
  // Terminal by contract, not by luck: it re-anchors Edit/Write inputs onto
402
404
  // the on-disk bytes, and the secrets it restores must NOT be re-stripped
403
- // by layer 3 or re-folded by layer 2 on a later pass.
405
+ // by the authored-content stage or re-folded on a later pass.
404
406
  terminal: true,
405
407
  run: async (tool, toolInput) => {
406
- const rehydrated = await rehydrate(tool, toolInput);
408
+ const rehydrated = await /** @type {NonNullable<typeof rehydrate>} */ (
409
+ rehydrate
410
+ )(tool, toolInput);
407
411
  if (!rehydrated) return null;
408
412
  if ("deny" in rehydrated) return { deny: rehydrated.deny };
409
413
  return {
@@ -421,12 +425,17 @@ export function preToolUseLayers(rehydrate, env = process.env) {
421
425
  // legitimately contain [REDACTED…] text — must not be re-fed to the
422
426
  // secret resolver or re-stripped by an earlier layer.
423
427
  terminal: true,
424
- run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
428
+ run: (tool, toolInput) => rehydrateLayer2(tool, toolInput, secretsOn),
425
429
  },
426
430
  ];
427
- return readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
428
- ? layers.filter((layer) => layer.name !== "authored-content")
429
- : layers;
431
+ return layers.filter(
432
+ (layer) =>
433
+ !(layer.name === "rehydrate" && rehydrate === null) &&
434
+ !(
435
+ layer.name === "authored-content" &&
436
+ readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
437
+ ),
438
+ );
430
439
  }
431
440
 
432
441
  // The tools whose path field Claude Code sends as an absolute path by
@@ -464,29 +473,36 @@ function toolTargetDir(tool, toolInput) {
464
473
  }
465
474
 
466
475
  /**
467
- * Compose the four protections. Returns the `hookSpecificOutput` fields to
468
- * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
469
- * caller fails closed (ask) on any throw. Every exit routes through emitTraced.
476
+ * Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
477
+ * null for a clean no-op. Throws only if a stage's engine throws; the caller
478
+ * then applies this hook's fault posture (open by default — see
479
+ * hookFailureFields). Every exit routes through emitTraced.
470
480
  * @param {any} input parsed PreToolUse event
471
481
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
472
- * injectable for tests; the default binds the real redactor-daemon io (the
473
- * layer reads the target file and maps secrets through the daemon)
482
+ * injectable for tests, and then run whatever the secret opt-in says; the
483
+ * default binds the real redactor-daemon io (the layer reads the target file
484
+ * and maps secrets through the daemon) and runs only with the opt-in on
474
485
  * @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
475
486
  * announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
487
+ * @param {import("./lib/layer-pipeline.mjs").Layer[]} [layers] the stage table,
488
+ * injectable so a test can drive one the shipped stages never produce (an
489
+ * oscillating stage); defaults to {@link preToolUseLayers} for this call
476
490
  * @returns {Promise<Record<string, unknown> | null>}
477
491
  */
478
- export async function buildPreToolUseResponse(
479
- input,
480
- rehydrate = defaultRehydrate,
481
- sink,
482
- ) {
492
+ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
483
493
  // Every path into the announcement runs through here, so this is the one
484
494
  // place a host sink has to be bound (see hookTrace).
485
495
  const emitTrace = hookTrace(sink);
496
+ // THE secret opt-in decision for this call: the default rehydrator, the
497
+ // Layer-2 rehydration prose and the placeholder advisory all read this one
498
+ // value. With it off the output hook never inserts placeholders, so there is
499
+ // nothing to re-anchor and an Edit never spawns the daemon. Read per call,
500
+ // not at module load, so a knob set after the bundle loads still governs.
501
+ const secretsOn = secretsEnabled();
486
502
  const asks = [];
487
503
  const contexts = [];
488
504
 
489
- // Layer 1: gate. Persists across the session until the injected files are
505
+ // The gate. Persists across the session until the injected files are
490
506
  // cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
491
507
  // degrades to a passive reminder, so it doesn't prompt on every tool call.
492
508
  const findings = invisibleCharAlert(input.session_id);
@@ -514,21 +530,46 @@ export async function buildPreToolUseResponse(
514
530
  );
515
531
  if (gapNotice !== null) contexts.push(gapNotice);
516
532
 
517
- // Layers 2-4, run by the declared pipeline: the driver — not this call order —
518
- // is what keeps the confusable fold's soundness precondition true once an
519
- // erasing layer follows it (see lib/layer-pipeline.mjs).
533
+ // The rewriting stages, run by the declared pipeline: the driver — not this
534
+ // call order — is what keeps the confusable fold's soundness precondition
535
+ // true once an erasing stage follows it (see lib/layer-pipeline.mjs).
520
536
  const {
521
537
  updatedInput: current,
522
538
  changed,
523
539
  contexts: layerContexts,
524
540
  deny,
525
- } = await runLayerPipeline(tool, toolInput, preToolUseLayers(rehydrate));
541
+ fault,
542
+ } = await runLayerPipeline(
543
+ tool,
544
+ toolInput,
545
+ layers ??
546
+ preToolUseLayers(
547
+ rehydrate ?? (secretsOn ? guardedRehydrate : null),
548
+ process.env,
549
+ secretsOn,
550
+ ),
551
+ );
526
552
  if (deny !== undefined)
527
553
  return emitTraced(emitTrace, input.tool_name, {
528
554
  permissionDecision: PermissionDecision.DENY,
529
555
  permissionDecisionReason: deny,
530
556
  });
531
557
  contexts.push(...layerContexts);
558
+ // A body that never settled: the pipeline hands back its LAST pass's sanitized
559
+ // input plus the reason, and the verdict is this hook's registered fault
560
+ // posture — never a verdict decided here. Open proceeds with `current` (the
561
+ // sanitized value, not the raw input) and the reason as context; closed
562
+ // returns the policy's closed verdict. `input` lets the policy's write-shaped
563
+ // placeholder carve-out judge the value that would actually be written.
564
+ if (fault !== undefined) {
565
+ const faultFields = hookFailureFields(true, new Error(fault), {
566
+ input: { tool_name: tool, tool_input: current },
567
+ });
568
+ if (faultFields.permissionDecisionReason !== undefined)
569
+ asks.push(String(faultFields.permissionDecisionReason));
570
+ if (faultFields.additionalContext !== undefined)
571
+ contexts.push(String(faultFields.additionalContext));
572
+ }
532
573
 
533
574
  // Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
534
575
  // anything unknown): rehydration cannot re-anchor these, so a placeholder in
@@ -538,7 +579,7 @@ export async function buildPreToolUseResponse(
538
579
  // what the tool will actually receive.
539
580
  // Gated on the secret opt-in like the layer itself: with secrets off,
540
581
  // placeholder-shaped text is ordinary prose and the advisory is noise.
541
- const notice = secretsEnabled() ? placeholderNotice(tool, current) : null;
582
+ const notice = secretsOn ? placeholderNotice(tool, current) : null;
542
583
  if (notice !== null) contexts.push(notice);
543
584
  // Same advisory for keyed Layer-2 splice placeholders (disjoint grammar, own
544
585
  // store): a Bash/MCP write path would persist them literally too, and the
@@ -589,10 +630,6 @@ function assembleResponse({
589
630
  if (changed) fields.updatedInput = current;
590
631
  if (asks.length > 0) {
591
632
  fields.permissionDecision = PermissionDecision.ASK;
592
- // Stryker disable next-line StringLiteral: the gate is the only source that
593
- // pushes onto `asks`, so the array never holds more than one reason and the
594
- // separator is unobservable — join("") is equivalent. The paragraph break is
595
- // kept for the day a second ask source is added.
596
633
  fields.permissionDecisionReason = asks.join("\n\n");
597
634
  }
598
635
  if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
@@ -604,13 +641,13 @@ function assembleResponse({
604
641
  }
605
642
 
606
643
  /**
607
- * Agent-agnostic judge over the four protections: consumes a control-plane
644
+ * Agent-agnostic judge over the five stages: consumes a control-plane
608
645
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
609
646
  * sanitization pipeline through its own adapter. The wired Claude CLI below
610
647
  * routes through this judge and renders the Verdict with the Claude adapter; on
611
648
  * any throw (a control-plane package-load failure included) it falls back to
612
- * failClosedFields — a native response that needs no package — so the
613
- * fail-closed posture holds even when the adapter never loaded.
649
+ * hookFailureFields — a native response that needs no package — so the
650
+ * caller's fault posture holds even when the adapter never loaded.
614
651
  * @param {import("agent-control-plane-core").ToolCallEvent} event
615
652
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
616
653
  * @param {{
@@ -629,10 +666,10 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
629
666
  const { gates = [], trace: sink } = opts;
630
667
  // MERGED over the defaults, never substituted for them. A host that overrides
631
668
  // one field would otherwise leave the rest undefined, and the miss lands in
632
- // the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
669
+ // the fault path: hookFailureFields runs inside runJudgeCli's catch, so a
633
670
  // TypeError on a missing field escapes the handler, the hook exits with no
634
671
  // stdout, and Claude reads a PreToolUse hook that produced no response as
635
- // non-blocking — the fail-closed ask becomes a fail-OPEN pass.
672
+ // non-blocking — the posture's ask or warning becomes a silent pass.
636
673
  const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
637
674
  const { Decision, EventKind } = controlPlane();
638
675
  // A payload the adapter cannot classify (a missing/unexpected hook_event_name)
@@ -760,12 +797,11 @@ export function failClosedFields(parsedOk, err, opts = {}) {
760
797
  };
761
798
  }
762
799
 
763
- // The redaction-placeholder prefix, restated as a literal rather than imported:
764
- // this check belongs to the FAILURE posture and must work exactly when the
765
- // agent-sanitizer package (which exports it as DEFAULT_HINT) failed to load.
766
- // Exported so test/claude-hooks-fail-open.test.mjs can pin the two spellings
767
- // together with exact equality — a prefix-only behavioral check would keep
768
- // passing if this literal drifted shorter and the carve-out over-triggered.
800
+ // The redaction-placeholder prefix the carve-out's reason names, restated as a
801
+ // literal rather than imported: the reason is built on the FAILURE path, which
802
+ // must work exactly when the agent-sanitizer package (which exports it as
803
+ // DEFAULT_HINT) failed to load. Exported so test/claude-hooks-fail-open.test.mjs
804
+ // can pin the two spellings together.
769
805
  export const REDACTION_HINT = "[REDACTED";
770
806
  // The file-editing tools whose input fields ARE the bytes persisted to disk.
771
807
  // Deliberately NOT exhaustive over every clobber path: Bash can also write a
@@ -782,14 +818,15 @@ export const WRITE_SHAPED_TOOLS = new Set([
782
818
 
783
819
  /**
784
820
  * True when a faulting PreToolUse call is the one case the OPEN posture must
785
- * still not pass: a write-shaped tool whose input carries the
786
- * redaction-placeholder prefix. Such a placeholder stands for a secret the
787
- * sanitizer redacted out of the model's view; with the sanitizer down,
788
- * rehydration cannot translate it back, so letting the call through would
789
- * persist the literal placeholder text over the real secret on disk — a
790
- * destructive clobber, not a missed scan. Package-free by construction (a Set
791
- * lookup and a substring test on the already-parsed payload), so it holds when
792
- * the failure IS the missing package.
821
+ * still not pass: a write-shaped tool whose input carries a redaction
822
+ * placeholder, matched by the real grammar (containsPlaceholder) so
823
+ * hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
824
+ * placeholder stands for a secret the sanitizer redacted out of the model's
825
+ * view; with the sanitizer down, rehydration cannot translate it back, so
826
+ * letting the call through would persist the literal placeholder text over the
827
+ * real secret on disk — a destructive clobber, not a missed scan.
828
+ * Package-free by construction (a Set lookup and a walk of the already-parsed
829
+ * payload), so it holds when the failure IS the missing package.
793
830
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
794
831
  * @returns {boolean}
795
832
  */
@@ -798,11 +835,10 @@ export function hintedWriteFault(input) {
798
835
  /** @type {{tool_name?: unknown, tool_input?: unknown} | undefined} */ (
799
836
  input
800
837
  );
801
- if (!WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)))
802
- return false;
803
- // A parsed-JSON payload can hold no circular reference, so stringify cannot
804
- // throw; `?? null` keeps a missing tool_input from stringifying to undefined.
805
- return JSON.stringify(payload?.tool_input ?? null).includes(REDACTION_HINT);
838
+ return (
839
+ WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)) &&
840
+ containsPlaceholder(payload?.tool_input)
841
+ );
806
842
  }
807
843
 
808
844
  /**
@@ -918,12 +954,9 @@ export async function cliMain(opts = {}) {
918
954
  trace: sink,
919
955
  }),
920
956
  {
921
- // The caller's posture, WITHOUT the package: pass through with a warning
922
- // by default, or — under AGENT_SANITIZER_FAIL_OPEN=0 — hard-deny an
923
- // unparsable INPUT (`input` undefined; adversary-inducible, no benefit to
924
- // failing) and ASK on any throw after a clean parse, keeping a human in
925
- // the loop. emitHookResponse renders natively, so either posture holds
926
- // even when the adapter never loaded.
957
+ // The registered fault policy (hookFailureFields → lib/hook-fault.mjs),
958
+ // WITHOUT the package: emitHookResponse renders natively, so the policy
959
+ // holds even when the adapter never loaded.
927
960
  onError: (err, input) =>
928
961
  emitHookResponse(
929
962
  HookEvent.PRE_TOOL_USE,