agent-sanitizer 2.26.3 → 2.27.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.
package/README.md CHANGED
@@ -58,7 +58,7 @@ the callback you inject for the agent-specific concern; `—` is a pure transfor
58
58
  | 5 | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
59
59
  | 6 | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
60
60
  | 7 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
61
- | 8 | `/rehydrate` | Re-anchor a model Edit composed from the _sanitized_ view back onto real bytes; deny anything ambiguous or secret-exposing. | `io` |
61
+ | 8 | `/rehydrate` | Re-anchor a model Edit/Write composed from the _sanitized_ view back onto real bytes; gate MultiEdit on a verified view==disk; deny anything ambiguous or secret-exposing. | `io` |
62
62
  | — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
63
63
 
64
64
  See [`THREAT-MODEL.md`](./THREAT-MODEL.md) for per-vector detail.
@@ -120,6 +120,25 @@ a single ordered pass, so the bytes a filter can remove are exactly the bytes it
120
120
  spans matched in the input — an earlier deletion can never manufacture a match
121
121
  for a later span (overlapping spans resolve first-match-wins).
122
122
 
123
+ ## Secret redaction
124
+
125
+ Secrets in tool output are redacted **locally**, before the model ever sees
126
+ them. The engine is an injected seam — the plugin wires
127
+ [`detect-secrets`](https://github.com/Yelp/detect-secrets), running entirely
128
+ on-machine; the library bundles no engine. The model reads stable `[REDACTED…]`
129
+ placeholders instead of the values. The write path closes the loop: Edits
130
+ composed against the redacted view are re-anchored onto the real bytes, and
131
+ placeholders in new content resolve back to the real secrets — disk → tool
132
+ input only, never into the model's view. Anything ambiguous is denied rather
133
+ than guessed, the redactor's own map is verified against the file before any
134
+ splice, and a write that would persist placeholder text over a real secret
135
+ asks instead of passing through even when the hook's own machinery fails
136
+ mid-session. The whole layer is opt-in — its denies and asks are friction, so it
137
+ engages only when asked for: set `AGENT_SANITIZER_SECRETS_ENABLED=1` in the
138
+ environment Claude Code runs the hooks with; unset, no redactor runs and no
139
+ placeholders exist.
140
+ Per-vector detail in [`THREAT-MODEL.md`](./THREAT-MODEL.md).
141
+
123
142
  ## What installing entails
124
143
 
125
144
  Installing the plugin puts four hooks on every session, and this is what they
@@ -136,8 +155,9 @@ buy you:
136
155
  walk a command past a deny rule.
137
156
  4. Tool output has invisible characters and terminal escapes stripped, hidden
138
157
  HTML spliced out with a placeholder, and exfil-shaped URLs flagged.
139
- 5. Secrets in tool output are redacted locally by `detect-secrets` — the engine
140
- ships with the plugin and provisions itself on first run, no setup from you.
158
+ 5. With `AGENT_SANITIZER_SECRETS_ENABLED=1` set, secrets in tool output are
159
+ redacted locally by `detect-secrets` the engine ships with the plugin and
160
+ provisions itself on first run, no further setup from you.
141
161
  6. Edits the model composes against the redacted view are re-anchored onto the
142
162
  real bytes on disk, and anything ambiguous is denied rather than guessed.
143
163
  7. The costs are a few seconds on the first secret-shaped output, ~200 ms on the
@@ -150,7 +170,10 @@ your session on its own breakage — but it says so, in a warning the model and
150
170
  the transcript both carry. Set `AGENT_SANITIZER_FAIL_OPEN=0` and the same
151
171
  failures block instead: suppressed tool output
152
172
  (`[output sanitizer unavailable — original output suppressed]`), blocked
153
- prompts, permission asks whose reason names the cause. Either way, a plugin that
173
+ prompts, permission asks whose reason names the cause. One carve-out to the
174
+ open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]` placeholder text asks
175
+ instead of passing through when the hook itself is broken, since letting it
176
+ through would overwrite the real secret with the placeholder. Either way, a plugin that
154
177
  never loaded at all is invisible — Claude Code reads a crashed hook as "no
155
178
  objection" — so confirm with `/plugin` rather than reading a quiet session as a
156
179
  working one. Neither posture touches what a sanitizer that RAN decided (see
package/THREAT-MODEL.md CHANGED
@@ -284,7 +284,14 @@ RECONSTITUTES during stripping is judged as the sequence it becomes.
284
284
  (a harness that gets a shape-mismatched value silently shows the raw output).
285
285
  Layer 4 (secret redaction) is an **injected** redactor and is the one
286
286
  fail-closed path: a redactor that throws makes the pipeline rethrow, so the
287
- caller suppresses the output rather than emit an unvetted value. Layer 5 is a
287
+ caller suppresses the output rather than emit an unvetted value. In the Claude
288
+ Code hooks the entire secret layer is **opt-in**: `secretsEnabled()`
289
+ (`claude-hooks/lib/env-config.mjs`) reads `AGENT_SANITIZER_SECRETS_ENABLED=1`,
290
+ and every secret-layer guarantee below — Layer-4 redaction, rehydration, the
291
+ placeholder guards, the placeholder-write carve-out, SessionStart engine
292
+ provisioning — is conditional on that knob being set; unset, no redactor is
293
+ spawned and no placeholders enter the model's view, because the layer's denies
294
+ and asks are friction an operator must ask for. Layer 5 is a
288
295
  deliberately thin, safe slot: the injected filter returns **verbatim spans to
289
296
  delete** (never replacement text), so even a compromised filter can only remove
290
297
  legitimate content—it can never inject bytes into the model’s view. That removal
@@ -334,6 +341,28 @@ are load-bearing and **fail closed**:
334
341
  edit would re-run redaction on every `Edit` call and risk false denials on a
335
342
  legitimate relabel in a large file. The secret flows disk → tool input only;
336
343
  the model’s next view is sanitized again.
344
+ - **Never trust the redactor’s map unverified.** The redactor’s map-mode output
345
+ is validated before any splice: every pair’s placeholder must occupy the view
346
+ text at its stated offset, and splicing the originals back must reconstruct
347
+ the file’s cleaned bytes exactly — out-of-range and overlapping pairs are
348
+ caught too. A map that fails either proof is **denied outright** — for every
349
+ tool and hint state, stricter than the honest-unmappable arm — and never
350
+ thrown into the host’s fail-open posture: unlike an engine reporting it
351
+ cannot map, a validated-wrong map is affirmative evidence the engine is wrong
352
+ about where this file’s secrets sit, so neither a splice nor a raw
353
+ pass-through (the R1 hidden-span oracle) can be vetted against it.
354
+
355
+ MultiEdit is gated, not rehydrated. MultiEdit applies its edits sequentially,
356
+ each against the file state the previous edit produced, so the span-exact
357
+ view↔disk mapping done for a single Edit has no sound equivalent. It passes
358
+ through only when the file’s sanitized view provably equals disk AND no edit
359
+ carries a `[REDACTED…]` placeholder; every other case — a view that diverges via
360
+ redacted secrets or stripped invisible characters, an unmappable or defective
361
+ redactor map, or placeholder text aimed at a pristine file (the
362
+ foreign-placeholder rule) — is **denied**, with guidance to re-issue the changes
363
+ as single Edit calls, which _are_ rehydrated. The previous full pass-through was
364
+ both a silent clobber (the placeholder persisted over the secret) and a
365
+ character-extraction oracle.
337
366
 
338
367
  File access and the redactor are injected via `io`; the package performs no I/O
339
368
  of its own and bundles no secret engine.
@@ -439,6 +468,24 @@ every way a hook can fail: the launcher not starting (no `node`, missing or
439
468
  corrupt bundle), the package never loading, a payload that never parsed, and a
440
469
  layer that ran and threw.
441
470
 
471
+ One carve-out: when the PreToolUse hook itself fails (redactor daemon down,
472
+ package failed to load, a layer threw) and the call is a **write-shaped tool**
473
+ (Write/Edit/MultiEdit/NotebookEdit) whose input carries the `[REDACTED`
474
+ placeholder prefix, the hook **asks** — fail-closed, human in the loop — instead
475
+ of passing through. With the sanitizer down, rehydration cannot run, so the
476
+ placeholder text would be persisted literally over the real secret on disk: a
477
+ destructive clobber, not a missed scan. Holding these calls is safe because a
478
+ placeholder-bearing write is never the benign availability case the open default
479
+ protects — the model can retry once the sanitizer recovers, or ask the user. The
480
+ check is package-free (a literal-string test on the already-parsed payload), so
481
+ it holds even when the failure IS the missing package. Two accepted gaps: a
482
+ launcher-level failure (no `node`, corrupt bundle) never reaches the check — the
483
+ launcher cannot inspect the payload and always warns — and `Bash` is excluded
484
+ even though shell redirection can also persist placeholder text, because command
485
+ strings mention `[REDACTED` benignly far too often for the ask to hold
486
+ precision. All other faults keep the open default, and
487
+ `AGENT_SANITIZER_FAIL_OPEN=0` behavior is unchanged.
488
+
442
489
  **The open default is not enforceable against content.** Several of those
443
490
  failures are composable by whoever authored the payload — in the output hook
444
491
  alone, the key-collision guard (two field names that collapse to one after
@@ -213,6 +213,27 @@ export function extraSecretVars(env = process.env) {
213
213
  return tokens;
214
214
  }
215
215
 
216
+ // The one switch for the whole secret layer (Layer 4). Secret redaction is
217
+ // OPT-IN: it spawns a Python daemon, rewrites the model's view of tool output,
218
+ // and gates the write path (rehydration denies, the placeholder-write
219
+ // carve-out) — machinery whose false positives cost real work, so it engages
220
+ // only when an operator asked for it. Every secret-layer call site consults
221
+ // THIS predicate; a second reading of the variable is the drift channel that
222
+ // would let one hook redact while another passes placeholders through.
223
+ export const SECRETS_ENABLED_ENV = "AGENT_SANITIZER_SECRETS_ENABLED";
224
+
225
+ /**
226
+ * True when the operator opted into the secret-redaction layer. `=== "1"`
227
+ * matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
228
+ * value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
229
+ * toward the default (no secret machinery), never silently enable it.
230
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
231
+ * @returns {boolean}
232
+ */
233
+ export function secretsEnabled(env = process.env) {
234
+ return env[SECRETS_ENABLED_ENV] === "1";
235
+ }
236
+
216
237
  /**
217
238
  * The env-bound redaction set: the UNION of the inference keys, the curated host
218
239
  * credentials, any credential-shaped var present in the environment, the
@@ -145,11 +145,15 @@ export function faultPolicy(hook) {
145
145
  * recording that the guarded content passed through unsanitized. Non-empty
146
146
  * matters — an empty stdout is recorded by Claude Code as a CLEAN run rather
147
147
  * than a degraded one, so the posture would give up visibility as well as
148
- * enforcement.
148
+ * enforcement. Exported so a policy that declares its own `open` arm for a
149
+ * carve-out can still route its non-carve-out path through THIS rendering
150
+ * instead of restating it — a hand-copied body would silently drift the day
151
+ * this one changes, which is the per-hook re-derivation this module exists to
152
+ * end.
149
153
  * @param {FaultContext} ctx
150
154
  * @returns {FaultParts}
151
155
  */
152
- function defaultOpen(ctx) {
156
+ export function defaultOpen(ctx) {
153
157
  return { fields: { additionalContext: ctx.openContext } };
154
158
  }
155
159
 
@@ -39,7 +39,11 @@ import {
39
39
  HookEvent,
40
40
  PermissionDecision,
41
41
  } from "./lib/hook-io.mjs";
42
- import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
42
+ import {
43
+ registerFaultPolicy,
44
+ hookFaultOutcome,
45
+ defaultOpen,
46
+ } from "./lib/hook-fault.mjs";
43
47
  import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
44
48
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
45
49
  import {
@@ -54,6 +58,7 @@ import {
54
58
  authoredContext,
55
59
  } from "./lib/authored-content.mjs";
56
60
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
61
+ import { secretsEnabled } from "./lib/env-config.mjs";
57
62
  import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
58
63
  import { placeholderNotice } from "./lib/placeholder-grammar.mjs";
59
64
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
@@ -147,11 +152,24 @@ const redactorIo = {
147
152
  * (not an inline default-param arrow) so tests can still inject a fake as the
148
153
  * second argument to buildPreToolUseResponse.
149
154
  */
150
- const defaultRehydrate = withSecretDropGuard(
155
+ const guardedRehydrate = withSecretDropGuard(
151
156
  (tool, toolInput) => rehydrateRedacted(tool, toolInput, redactorIo),
152
157
  redactorIo,
153
158
  );
154
159
 
160
+ /**
161
+ * The wired default gates the whole rehydration layer on the secret opt-in:
162
+ * with secrets off the output hook never inserts placeholders, so there is
163
+ * nothing to re-anchor — and skipping here (rather than inside the layer)
164
+ * means an Edit on a plain file never touches the file system twice or spawns
165
+ * the daemon. Consulted per call, not at module load, so a knob set after the
166
+ * bundle loads still governs the next tool call.
167
+ * @param {string} tool
168
+ * @param {any} toolInput
169
+ */
170
+ const defaultRehydrate = async (tool, toolInput) =>
171
+ secretsEnabled() ? guardedRehydrate(tool, toolInput) : null;
172
+
155
173
  /**
156
174
  * Trace the response on the way out — "noop" (clean pass-through), "deny",
157
175
  * "ask", or "modified" (input rewritten and/or context attached) — and return
@@ -312,7 +330,9 @@ export async function buildPreToolUseResponse(
312
330
  // cannot tell a write from a read, so it is context-only — never a verdict
313
331
  // (see placeholderNotice). Evaluated on the pipeline's FINAL input, matching
314
332
  // what the tool will actually receive.
315
- const notice = placeholderNotice(tool, current);
333
+ // Gated on the secret opt-in like the layer itself: with secrets off,
334
+ // placeholder-shaped text is ordinary prose and the advisory is noise.
335
+ const notice = secretsEnabled() ? placeholderNotice(tool, current) : null;
316
336
  if (notice !== null) contexts.push(notice);
317
337
 
318
338
  return emitTraced(
@@ -496,12 +516,60 @@ export function failClosedFields(parsedOk, err, opts = {}) {
496
516
  };
497
517
  }
498
518
 
519
+ // The redaction-placeholder prefix, restated as a literal rather than imported:
520
+ // this check belongs to the FAILURE posture and must work exactly when the
521
+ // agent-sanitizer package (which exports it as DEFAULT_HINT) failed to load.
522
+ // Exported so test/claude-hooks-fail-open.test.mjs can pin the two spellings
523
+ // together with exact equality — a prefix-only behavioral check would keep
524
+ // passing if this literal drifted shorter and the carve-out over-triggered.
525
+ export const REDACTION_HINT = "[REDACTED";
526
+ // The file-editing tools whose input fields ARE the bytes persisted to disk.
527
+ // Deliberately NOT exhaustive over every clobber path: Bash can also write a
528
+ // placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
529
+ // mention "[REDACTED" benignly far too often — grepping for it, discussing
530
+ // it — for an ask to hold precision there. That is an accepted gap, named in
531
+ // THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
532
+ const WRITE_SHAPED_TOOLS = new Set([
533
+ "Write",
534
+ "Edit",
535
+ "MultiEdit",
536
+ "NotebookEdit",
537
+ ]);
538
+
539
+ /**
540
+ * True when a faulting PreToolUse call is the one case the OPEN posture must
541
+ * still not pass: a write-shaped tool whose input carries the
542
+ * redaction-placeholder prefix. Such a placeholder stands for a secret the
543
+ * sanitizer redacted out of the model's view; with the sanitizer down,
544
+ * rehydration cannot translate it back, so letting the call through would
545
+ * persist the literal placeholder text over the real secret on disk — a
546
+ * destructive clobber, not a missed scan. Package-free by construction (a Set
547
+ * lookup and a substring test on the already-parsed payload), so it holds when
548
+ * the failure IS the missing package.
549
+ * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
550
+ * @returns {boolean}
551
+ */
552
+ export function hintedWriteFault(input) {
553
+ const payload =
554
+ /** @type {{tool_name?: unknown, tool_input?: unknown} | undefined} */ (
555
+ input
556
+ );
557
+ if (!WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)))
558
+ return false;
559
+ // A parsed-JSON payload can hold no circular reference, so stringify cannot
560
+ // throw; `?? null` keeps a missing tool_input from stringifying to undefined.
561
+ return JSON.stringify(payload?.tool_input ?? null).includes(REDACTION_HINT);
562
+ }
563
+
499
564
  /**
500
565
  * The hookSpecificOutput fields for a hook-level failure under the CALLER's
501
566
  * chosen posture: fail-OPEN by default — a warning context and no
502
567
  * permissionDecision, so the tool call proceeds unsanitized — or the
503
568
  * fail-CLOSED verdict of {@link failClosedFields} when the caller set
504
- * AGENT_SANITIZER_FAIL_OPEN=0.
569
+ * AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
570
+ * this hook's fault policy below: a write-shaped call whose input carries a
571
+ * `[REDACTED…]` placeholder asks instead of passing (see
572
+ * {@link hintedWriteFault}) — pass `input` so the policy can see it.
505
573
  *
506
574
  * The posture covers this hook's own failures, whatever their cause. What it
507
575
  * does NOT cover is the verdict of a sanitizer that ran: a payload
@@ -512,6 +580,7 @@ export function failClosedFields(parsedOk, err, opts = {}) {
512
580
  * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
513
581
  * hint?: string,
514
582
  * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
583
+ * input?: unknown,
515
584
  * }} [opts]
516
585
  * @returns {Record<string, unknown>}
517
586
  */
@@ -522,16 +591,51 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
522
591
  env: opts.env,
523
592
  messages: opts.messages,
524
593
  hint: opts.hint,
594
+ input: opts.input,
525
595
  }).fields
526
596
  );
527
597
  }
528
598
 
529
599
  // This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
530
- // is the shared default — a warning context and no verdict — so only the CLOSED
531
- // verdict, which is this hook's own ask/deny split, is stated here.
600
+ // keeps the shared default — a warning context and no verdict — with ONE
601
+ // carve-out: a write-shaped input carrying a [REDACTED… placeholder asks
602
+ // instead. The open posture trades enforcement for availability on the
603
+ // sanitizer's own breakage, and that trade is sound for a missed SCAN; a
604
+ // placeholder-bearing write is not a scan but a guaranteed clobber — the
605
+ // placeholder would be persisted literally over the secret it stands for (see
606
+ // hintedWriteFault). The ask keeps a human in the loop exactly as the closed
607
+ // posture's clean-parse arm does.
532
608
  registerFaultPolicy(HOOK_NAME, {
533
609
  event: HookEvent.PRE_TOOL_USE,
534
610
  guarded: "tool input",
611
+ open: (ctx) => {
612
+ // Non-carve-out faults take the SHARED open rendering, not a copy of it —
613
+ // hook-fault.mjs owns that body, and a restated one would silently drift.
614
+ // The carve-out itself rides the secret opt-in: with secrets off no
615
+ // sanitized view ever handed the model a placeholder, so hint-shaped text
616
+ // in a write is literal prose and holding it would be a false positive.
617
+ if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
618
+ return defaultOpen(ctx);
619
+ // parsedOk is hardcoded true (the ASK arm): hintedWriteFault(undefined)
620
+ // is false, so an unparsed input can never reach this line — reaching it
621
+ // proves the payload parsed.
622
+ const closed = failClosedFields(true, ctx.err, {
623
+ messages: ctx.messages,
624
+ hint: ctx.hint,
625
+ });
626
+ return {
627
+ fields: {
628
+ ...closed,
629
+ permissionDecisionReason:
630
+ `${closed.permissionDecisionReason} This input would write ` +
631
+ `${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
632
+ `secret the unavailable sanitizer cannot translate back; proceeding ` +
633
+ `would overwrite the real secret with the placeholder, so the call ` +
634
+ `is held even under the fail-open posture. Retry once the sanitizer ` +
635
+ `recovers, or ask the user to make this change.`,
636
+ },
637
+ };
638
+ },
535
639
  closed: (ctx) => ({
536
640
  fields: failClosedFields(ctx.parsedOk, ctx.err, {
537
641
  messages: ctx.messages,
@@ -580,6 +684,7 @@ export async function cliMain(opts = {}) {
580
684
  hookFailureFields(input !== undefined, err, {
581
685
  messages,
582
686
  hint: depLoadHint(err, messages.remedy),
687
+ input,
583
688
  }),
584
689
  ),
585
690
  },
@@ -36,6 +36,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
36
36
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
37
37
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
38
38
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
39
+ import { secretsEnabled } from "./lib/env-config.mjs";
39
40
  import {
40
41
  persistReveal,
41
42
  isRevealRead,
@@ -252,32 +253,36 @@ export async function sanitizeText(
252
253
  exfilScan: webIngress,
253
254
  sgrCarveOut: !webIngress,
254
255
  deadline,
255
- // Layer 4 — the seam rethrows a redactor throw wrapped, and the CLI applies
256
- // the caller's posture to it. Surface the failure to the operator's
257
- // terminal here first: whatever the CLI decides rides in additionalContext,
258
- // which only the model sees, so a degraded redactor would otherwise be
259
- // invisible to the human and under the fail-open default this line is the
260
- // ONLY signal the human gets.
261
- redact: async (/** @type {string} */ content) => {
262
- let secrets;
263
- try {
264
- secrets = await redactSecrets(content, webIngress, deadline);
265
- } catch (l4err) {
266
- process.stderr.write(
267
- `sanitize-output: CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
268
- "This output was never vetted for secrets. Fix the redactor installation.\n",
269
- );
270
- throw l4err;
271
- }
272
- if (!secrets) return null;
273
- // The note is derived from the PRE-redaction text: the caller's reason for
274
- // annotating (which variable, which provenance) is exactly what redaction
275
- // is about to remove.
276
- const note = ext.redactNote?.(content);
277
- return note
278
- ? { text: secrets.text, found: secrets.found, note }
279
- : { text: secrets.text, found: secrets.found };
280
- },
256
+ // Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
257
+ // redact callback at all, so plain output never spawns the daemon and no
258
+ // placeholder ever enters the model's view. When it runs, the seam rethrows
259
+ // a redactor throw wrapped, and the CLI applies the caller's posture to it.
260
+ // Surface the failure to the operator's terminal here first: whatever the
261
+ // CLI decides rides in additionalContext, which only the model sees, so a
262
+ // degraded redactor would otherwise be invisible to the human — and under
263
+ // the fail-open default this line is the ONLY signal the human gets.
264
+ redact: !secretsEnabled()
265
+ ? undefined
266
+ : async (/** @type {string} */ content) => {
267
+ let secrets;
268
+ try {
269
+ secrets = await redactSecrets(content, webIngress, deadline);
270
+ } catch (l4err) {
271
+ process.stderr.write(
272
+ `sanitize-output: CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
273
+ "This output was never vetted for secrets. Fix the redactor installation.\n",
274
+ );
275
+ throw l4err;
276
+ }
277
+ if (!secrets) return null;
278
+ // The note is derived from the PRE-redaction text: the caller's reason for
279
+ // annotating (which variable, which provenance) is exactly what redaction
280
+ // is about to remove.
281
+ const note = ext.redactNote?.(content);
282
+ return note
283
+ ? { text: secrets.text, found: secrets.found, note }
284
+ : { text: secrets.text, found: secrets.found };
285
+ },
281
286
  };
282
287
  const seamResult =
283
288
  /** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
@@ -776,7 +781,11 @@ export async function evaluateToolOutput(input, ext = {}) {
776
781
  for (const original of reveals) {
777
782
  let stored;
778
783
  try {
779
- const secrets = await redactSecrets(original, true, deadline);
784
+ // Same opt-in as the main pass: with secrets off nothing was redacted
785
+ // out of the primary output either, so the sidecar persists verbatim.
786
+ const secrets = secretsEnabled()
787
+ ? await redactSecrets(original, true, deadline)
788
+ : null;
780
789
  stored = secrets ? secrets.text : original;
781
790
  } catch {
782
791
  // The pre-splice text carries the spliced comment bodies, so a secret
@@ -795,7 +804,11 @@ export async function evaluateToolOutput(input, ext = {}) {
795
804
  // secret surfaces, while grep/Bash output quoting placeholders is routine.
796
805
  // Reveal sidecars are excluded — their bytes are redacted BEFORE persisting,
797
806
  // so placeholder text there is this sanitizer's own.
807
+ // Gated on the secret opt-in: with the layer off this sanitizer never
808
+ // inserts placeholders, so placeholder-shaped bytes are ordinary text and
809
+ // the warning would be pure noise on every doc ABOUT redaction.
798
810
  if (
811
+ secretsEnabled() &&
799
812
  input.tool_name === "Read" &&
800
813
  !revealRead &&
801
814
  containsPlaceholder(toolOutput)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.26.3",
3
+ "version": "2.27.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/rehydrate.mjs CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  makeFileView,
66
66
  toUtf16View,
67
67
  pairDiskSpans,
68
+ viewMapDefect,
68
69
  } from "./view-map.mjs";
69
70
 
70
71
  // Cheap gate: every redaction placeholder the canonical redactor emits starts
@@ -488,29 +489,32 @@ async function rehydrateWrite(ti, view, io, hint) {
488
489
  }
489
490
 
490
491
  /**
491
- * The single MultiEdit refusal: covers both a sanitized view that diverges
492
+ * The single MultiEdit refusal: covers a sanitized view that diverges
492
493
  * from disk (redacted secrets, stripped invisible characters, a lone
493
- * surrogate) and edits that carry placeholder text over a pristine file —
494
- * either way the sequential edits cannot be re-anchored, so route the model
495
- * to the per-call verified path.
494
+ * surrogate), a redactor map that is unmappable or failed validation, and
495
+ * edits that carry foreign placeholder text over a pristine file in every
496
+ * case the sequential edits cannot be re-anchored, so route the model to the
497
+ * per-call verified path.
496
498
  * @param {string} filePath
497
499
  */
498
500
  function multiEditDeny(filePath) {
499
501
  return {
500
502
  deny:
501
503
  `the sanitized view of ${filePath} differs from its on-disk bytes ` +
502
- `(redacted secrets or stripped invisible characters), or the edits carry ` +
503
- `[REDACTED…] placeholder text; MultiEdit's sequential edits cannot be ` +
504
- `re-anchored onto the real bytes. Use single Edit calls — each is ` +
505
- `rehydrated individuallyor ask the user to make this change`,
504
+ `(redacted secrets, stripped invisible characters, or normalized lone ` +
505
+ `surrogates), or the edits carry [REDACTED…] placeholder text; ` +
506
+ `MultiEdit's sequential edits cannot be re-anchored onto the real ` +
507
+ `bytes. Use single Edit calls each is rehydrated individually or ` +
508
+ `ask the user to make this change`,
506
509
  };
507
510
  }
508
511
 
509
512
  /**
510
513
  * True when this tool call could need re-anchoring against the target file's
511
514
  * sanitized view: any well-formed Edit (the view may differ from disk even
512
- * without placeholders, via stripped invisible characters), or a Write whose
513
- * content carries a placeholder.
515
+ * without placeholders, via stripped invisible characters), any well-formed
516
+ * MultiEdit (gated on the same grounds), or a Write whose content carries a
517
+ * placeholder.
514
518
  * @param {string} tool
515
519
  * @param {any} ti
516
520
  * @param {string} hint
@@ -521,8 +525,6 @@ function isCandidate(tool, ti, hint) {
521
525
  return (
522
526
  typeof ti.old_string === "string" && typeof ti.new_string === "string"
523
527
  );
524
- if (tool === "Write")
525
- return typeof ti.content === "string" && ti.content.includes(hint);
526
528
  // MultiEdit applies its edits SEQUENTIALLY, each against the result of the
527
529
  // previous, so the span machinery below (which maps one old_string against
528
530
  // one static view) cannot re-anchor it. It is still a candidate: on a
@@ -541,16 +543,20 @@ function isCandidate(tool, ti, hint) {
541
543
  typeof edit?.new_string === "string",
542
544
  )
543
545
  );
546
+ if (tool === "Write")
547
+ return typeof ti.content === "string" && ti.content.includes(hint);
544
548
  return false;
545
549
  }
546
550
 
547
551
  /**
548
552
  * Re-anchor an Edit/Write input composed from a sanitized file view back onto
549
- * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached).
550
- * Returns the rewritten input plus a model-facing context line, a deny with an
551
- * instructive reason when the input is unresolvable or would expose a secret,
552
- * or null when there is nothing to do. Throws only on internal error (the
553
- * caller fails closed).
553
+ * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached),
554
+ * and gate MultiEdit (pass-through only on a verified view==disk with no
555
+ * placeholder in any edit; denied otherwise see the module doc). Returns the
556
+ * rewritten input plus a model-facing context line, a deny with an instructive
557
+ * reason when the input is unresolvable or would expose a secret, or null when
558
+ * there is nothing to do. Throws only on internal error (the caller fails
559
+ * closed).
554
560
  *
555
561
  * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
556
562
  * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
@@ -599,21 +605,25 @@ export async function rehydrateRedacted(
599
605
  // promises Node-shaped read failures (a real `readFile`'s throw), so
600
606
  // narrow once here rather than re-deriving the cast at every use below.
601
607
  const nodeErr = /** @type {NodeJS.ErrnoException} */ (err);
602
- // ENOENT (missing target): an Edit fails on its own (nothing to
603
- // re-anchor), so pass through. A Write, though, CREATES the file with its
604
- // content verbatim and a Write candidate is always hinted (isCandidate
605
- // requires the hint prefix), so its placeholder stands for a secret that
606
- // does NOT exist on this new path. R4: persisting "[REDACTED…]" literally
607
- // there is the same cross-file/stale-placeholder mistake a same-file Write
608
- // is denied for; refuse with the same guidance rather than write the
609
- // placeholder text as a real value.
610
- // A hinted MultiEdit on a missing path is the same mistake: its first edit
611
- // (empty old_string) CREATES the file, so a placeholder in any edit would
612
- // be persisted verbatim as the new file's content deny like the Write.
613
- // A hint-free MultiEdit fails on its own (nothing to create placeholder
614
- // text from), so it passes through like an Edit.
608
+ // ENOENT (missing target): a call that cannot CREATE the file fails on
609
+ // its own (nothing to re-anchor), so pass through a hint-free call, an
610
+ // Edit whose old_string is non-empty, or a MultiEdit whose FIRST edit's
611
+ // old_string is non-empty (only an empty first old_string is the create
612
+ // form; anything else errors not-found in the real tool). But any call
613
+ // that WOULD create the file with hinted content — a Write (always
614
+ // hinted; isCandidate requires the prefix), a hinted Edit-create, or a
615
+ // hinted MultiEdit-create persists its placeholder verbatim, standing
616
+ // for a secret that does NOT exist on this new path. R4: that is the same
617
+ // cross-file/stale-placeholder mistake a same-file Write is denied for;
618
+ // refuse with the same guidance rather than write the placeholder text as
619
+ // a real value.
615
620
  if (nodeErr?.code === "ENOENT") {
616
- if (tool === "Edit" || (tool === "MultiEdit" && !hinted)) return null;
621
+ const creates =
622
+ tool === "Write" ||
623
+ (tool === "Edit"
624
+ ? toolInput.old_string === ""
625
+ : toolInput.edits[0].old_string === "");
626
+ if (!hinted || !creates) return null;
617
627
  return {
618
628
  deny:
619
629
  `${toolInput.file_path} does not exist, so the ${hint}…] placeholder in the ` +
@@ -680,9 +690,48 @@ export async function rehydrateRedacted(
680
690
  // already-converted object and get converted twice, so the same input would
681
691
  // yield a different verdict on the second call. The space brand is what makes
682
692
  // that second conversion throw rather than silently shift; see toUtf16View.
683
- const view = toUtf16View(
684
- makeFileView(mapped.text, mapped.pairs, "codePoint"),
685
- );
693
+ //
694
+ // The map is TRUSTED by every splice below, and it came from the injected
695
+ // redactor — the one component with a real defect rate. A wrong map
696
+ // (mis-ordered or out-of-range pairs, a placeholder not at its stated
697
+ // offset, originals that do not splice back to the file's bytes) would
698
+ // anchor an edit onto the WRONG disk bytes and corrupt the file. Verify it
699
+ // before acting: construction re-checks range/ordering (the throws in
700
+ // assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
701
+ // reconstructs `cleaned` exactly. A defective map DENIES for every tool and
702
+ // hint state, and is deliberately NOT allowed to throw out of this module: a
703
+ // throw lands in the host's failure posture, whose shipped default is fail
704
+ // OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
705
+ let view = null;
706
+ let mapDefect = null;
707
+ try {
708
+ view = toUtf16View(makeFileView(mapped.text, mapped.pairs, "codePoint"));
709
+ } catch (err) {
710
+ mapDefect = `the redactor's map violates its contract (${/** @type {Error} */ (err).message})`;
711
+ }
712
+ if (view !== null) {
713
+ const defect = viewMapDefect(cleaned, view);
714
+ if (defect !== null)
715
+ mapDefect = `the redactor's map is inconsistent with the file's bytes (${defect})`;
716
+ }
717
+ if (view === null || mapDefect !== null) {
718
+ // STRICTER than the unmappable arm above, on purpose: unmappable is an
719
+ // engine honestly reporting it cannot map, so an unhinted Edit keeps its
720
+ // resolver-free pass-through; a map that FAILED VALIDATION is affirmative
721
+ // evidence the engine is wrong about where this file's secrets sit.
722
+ // Reaching this line at all means the fast pass-through did not fire — the
723
+ // file provably holds redacted content or diverges from its view — so an
724
+ // unhinted pass-through here would hand the real Edit bytes inside spans
725
+ // nothing can vet (the R1 hidden-span oracle). Deny everything until the
726
+ // redactor is fixed.
727
+ if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
728
+ return {
729
+ deny:
730
+ `cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
731
+ `redacted content whose location cannot be trusted, so no edit can be ` +
732
+ `verified. Retry later, or ask the user to make this change`,
733
+ };
734
+ }
686
735
  // View identical to disk: any placeholders in an Edit's old_string are
687
736
  // literal text, so there is nothing to re-anchor. `cleaned === content` also
688
737
  // rules out a lone-surrogate-only divergence (view.pairs/deletions alone
@@ -694,19 +743,33 @@ export async function rehydrateRedacted(
694
743
  // rehydrateWrite's cross-file deny; a MultiEdit to the MultiEdit deny below
695
744
  // — without this a hinted MultiEdit on a pristine file silently persists
696
745
  // the foreign placeholder the byte-identical Write is denied for.
697
- if (
698
- view.pairs.length === 0 &&
699
- deletions.length === 0 &&
700
- cleaned === content &&
701
- (tool === "Edit" || !hinted)
702
- )
746
+ const viewEqualsDisk =
747
+ view.pairs.length === 0 && deletions.length === 0 && cleaned === content;
748
+ // Precision refinement for a hinted MultiEdit on that PRISTINE file: a
749
+ // hint token that already exists verbatim in the file is LITERAL prose
750
+ // (this repo's own docs carry "[REDACTED…" text), not a placeholder — the
751
+ // same whitelist foreignPlaceholders applies for a Write. Only new_string
752
+ // needs vetting: it is the sole field that persists bytes, while an
753
+ // old_string that is not literal file text simply fails the real tool's
754
+ // exact-match and persists nothing. The file holds no secrets here (the
755
+ // verified-empty map above proves it), so a fully-literal MultiEdit cannot
756
+ // clobber one; pass it through instead of false-denying documentation
757
+ // edits.
758
+ const multiEditLiteralHint =
759
+ tool === "MultiEdit" &&
760
+ viewEqualsDisk &&
761
+ toolInput.edits.every(
762
+ (/** @type {{new_string: string}} */ edit) =>
763
+ foreignPlaceholders(edit.new_string, hint, view.text, []).length === 0,
764
+ );
765
+ if (viewEqualsDisk && (tool === "Edit" || !hinted || multiEditLiteralHint))
703
766
  return null;
704
767
 
705
768
  // MultiEdit reaches here when the view diverges from disk (redacted
706
769
  // secrets, stripped runs, a lone surrogate) or when its edits carry
707
- // placeholder text: its sequential edits cannot be re-anchored one-by-one
708
- // against a static view, so fail closed with the escape hatch that lands in
709
- // the fully-verified path.
770
+ // foreign placeholder text: its sequential edits cannot be re-anchored
771
+ // one-by-one against a static view, so fail closed with the escape hatch
772
+ // that lands in the fully-verified path.
710
773
  if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
711
774
  return tool === "Edit"
712
775
  ? rehydrateEdit(
package/src/view-map.mjs CHANGED
@@ -491,6 +491,41 @@ export function pairDiskSpans(view, deletions) {
491
491
  });
492
492
  }
493
493
 
494
+ /**
495
+ * Defect in a redactor map relative to the Layer-1-cleaned text it claims to
496
+ * describe, or null when the map is sound. Two proofs, both required before
497
+ * any splice may trust the map: every pair's placeholder must actually occupy
498
+ * `view.text` at its stated offset, and splicing each pair's original back
499
+ * over its placeholder must reproduce `cleaned` byte-for-byte. The redactor is
500
+ * an INJECTED engine with a real defect rate, and the offsets it emits anchor
501
+ * edits onto disk bytes — a map failing either proof would splice at the wrong
502
+ * position and corrupt the file, so the caller must treat it as unmappable
503
+ * rather than act on it. Defect messages name placeholders and offsets only,
504
+ * never an original (secret) byte.
505
+ * Indexes `view.text` by `pair.start` directly, so it requires the UTF-16
506
+ * carrier — the same one every splice consumes.
507
+ * @param {string} cleaned Layer-1-cleaned file text the map was derived from
508
+ * @param {FileView<"utf16">} view
509
+ * @returns {string | null}
510
+ */
511
+ export function viewMapDefect(cleaned, view) {
512
+ assertFileView(view, "utf16", "viewMapDefect");
513
+ for (const pair of view.pairs)
514
+ if (!view.text.startsWith(pair.placeholder, pair.start))
515
+ return (
516
+ `the map places placeholder ${JSON.stringify(pair.placeholder)} at view ` +
517
+ `offset ${pair.start}, but the view text there differs`
518
+ );
519
+ const rebuilt = spliceOrdered(
520
+ view.text,
521
+ view.pairs.map((pair) => ({ text: pair.placeholder, index: pair.start })),
522
+ (_match, i) => view.pairs[i].original,
523
+ ).text;
524
+ return rebuilt === cleaned
525
+ ? null
526
+ : "substituting the mapped secrets back over their placeholders does not reconstruct the file";
527
+ }
528
+
494
529
  /**
495
530
  * Substitute the placeholders in a model-authored new_string with the secrets
496
531
  * they stand for. Resolution, strictest first: if the new placeholder
@@ -58,6 +58,15 @@ export function dynamicSecretVars(env?: Record<string, string | undefined>): str
58
58
  * @returns {string[]}
59
59
  */
60
60
  export function extraSecretVars(env?: Record<string, string | undefined>): string[];
61
+ /**
62
+ * True when the operator opted into the secret-redaction layer. `=== "1"`
63
+ * matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
64
+ * value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
65
+ * toward the default (no secret machinery), never silently enable it.
66
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
67
+ * @returns {boolean}
68
+ */
69
+ export function secretsEnabled(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): boolean;
61
70
  /**
62
71
  * The env-bound redaction set: the UNION of the inference keys, the curated host
63
72
  * credentials, any credential-shaped var present in the environment, the
@@ -69,3 +78,4 @@ export function extraSecretVars(env?: Record<string, string | undefined>): strin
69
78
  * @returns {string[]}
70
79
  */
71
80
  export function envBoundSecretVars(env?: Record<string, string | undefined>): string[];
81
+ export const SECRETS_ENABLED_ENV: "AGENT_SANITIZER_SECRETS_ENABLED";
@@ -15,6 +15,20 @@ export function registerFaultPolicy(hook: string, policy: FaultPolicy): void;
15
15
  * @returns {FaultPolicy}
16
16
  */
17
17
  export function faultPolicy(hook: string): FaultPolicy;
18
+ /**
19
+ * The default OPEN rendering: no verdict, and a non-empty `additionalContext`
20
+ * recording that the guarded content passed through unsanitized. Non-empty
21
+ * matters — an empty stdout is recorded by Claude Code as a CLEAN run rather
22
+ * than a degraded one, so the posture would give up visibility as well as
23
+ * enforcement. Exported so a policy that declares its own `open` arm for a
24
+ * carve-out can still route its non-carve-out path through THIS rendering
25
+ * instead of restating it — a hand-copied body would silently drift the day
26
+ * this one changes, which is the per-hook re-derivation this module exists to
27
+ * end.
28
+ * @param {FaultContext} ctx
29
+ * @returns {FaultParts}
30
+ */
31
+ export function defaultOpen(ctx: FaultContext): FaultParts;
18
32
  /**
19
33
  * Resolve `hook`'s response to its own failure under the caller's posture. This
20
34
  * is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
@@ -88,12 +88,29 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
88
88
  messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
89
89
  hint?: string;
90
90
  }): Record<string, unknown>;
91
+ /**
92
+ * True when a faulting PreToolUse call is the one case the OPEN posture must
93
+ * still not pass: a write-shaped tool whose input carries the
94
+ * redaction-placeholder prefix. Such a placeholder stands for a secret the
95
+ * sanitizer redacted out of the model's view; with the sanitizer down,
96
+ * rehydration cannot translate it back, so letting the call through would
97
+ * persist the literal placeholder text over the real secret on disk — a
98
+ * destructive clobber, not a missed scan. Package-free by construction (a Set
99
+ * lookup and a substring test on the already-parsed payload), so it holds when
100
+ * the failure IS the missing package.
101
+ * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
102
+ * @returns {boolean}
103
+ */
104
+ export function hintedWriteFault(input: unknown): boolean;
91
105
  /**
92
106
  * The hookSpecificOutput fields for a hook-level failure under the CALLER's
93
107
  * chosen posture: fail-OPEN by default — a warning context and no
94
108
  * permissionDecision, so the tool call proceeds unsanitized — or the
95
109
  * fail-CLOSED verdict of {@link failClosedFields} when the caller set
96
- * AGENT_SANITIZER_FAIL_OPEN=0.
110
+ * AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
111
+ * this hook's fault policy below: a write-shaped call whose input carries a
112
+ * `[REDACTED…]` placeholder asks instead of passing (see
113
+ * {@link hintedWriteFault}) — pass `input` so the policy can see it.
97
114
  *
98
115
  * The posture covers this hook's own failures, whatever their cause. What it
99
116
  * does NOT cover is the verdict of a sanitizer that ran: a payload
@@ -104,6 +121,7 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
104
121
  * messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
105
122
  * hint?: string,
106
123
  * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
124
+ * input?: unknown,
107
125
  * }} [opts]
108
126
  * @returns {Record<string, unknown>}
109
127
  */
@@ -111,6 +129,7 @@ export function hookFailureFields(parsedOk: boolean, err: unknown, opts?: {
111
129
  messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
112
130
  hint?: string;
113
131
  env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
132
+ input?: unknown;
114
133
  }): Record<string, unknown>;
115
134
  /**
116
135
  * The hook's CLI: parse → judge → render, under the caller's failure posture.
@@ -155,6 +174,7 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
155
174
  unparsable: (cause: string) => string;
156
175
  remedy: string;
157
176
  }>;
177
+ export const REDACTION_HINT: "[REDACTED";
158
178
  /**
159
179
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
160
180
  * must be blocked, or null to let the pipeline continue. Hosts use these for
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Re-anchor an Edit/Write input composed from a sanitized file view back onto
3
- * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached).
4
- * Returns the rewritten input plus a model-facing context line, a deny with an
5
- * instructive reason when the input is unresolvable or would expose a secret,
6
- * or null when there is nothing to do. Throws only on internal error (the
7
- * caller fails closed).
3
+ * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached),
4
+ * and gate MultiEdit (pass-through only on a verified view==disk with no
5
+ * placeholder in any edit; denied otherwise see the module doc). Returns the
6
+ * rewritten input plus a model-facing context line, a deny with an instructive
7
+ * reason when the input is unresolvable or would expose a secret, or null when
8
+ * there is nothing to do. Throws only on internal error (the caller fails
9
+ * closed).
8
10
  *
9
11
  * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
10
12
  * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
@@ -216,6 +216,24 @@ export function pairDiskSpans(view: FileView<"utf16">, deletions: {
216
216
  start: number;
217
217
  end: number;
218
218
  }[];
219
+ /**
220
+ * Defect in a redactor map relative to the Layer-1-cleaned text it claims to
221
+ * describe, or null when the map is sound. Two proofs, both required before
222
+ * any splice may trust the map: every pair's placeholder must actually occupy
223
+ * `view.text` at its stated offset, and splicing each pair's original back
224
+ * over its placeholder must reproduce `cleaned` byte-for-byte. The redactor is
225
+ * an INJECTED engine with a real defect rate, and the offsets it emits anchor
226
+ * edits onto disk bytes — a map failing either proof would splice at the wrong
227
+ * position and corrupt the file, so the caller must treat it as unmappable
228
+ * rather than act on it. Defect messages name placeholders and offsets only,
229
+ * never an original (secret) byte.
230
+ * Indexes `view.text` by `pair.start` directly, so it requires the UTF-16
231
+ * carrier — the same one every splice consumes.
232
+ * @param {string} cleaned Layer-1-cleaned file text the map was derived from
233
+ * @param {FileView<"utf16">} view
234
+ * @returns {string | null}
235
+ */
236
+ export function viewMapDefect(cleaned: string, view: FileView<"utf16">): string | null;
219
237
  /**
220
238
  * Substitute the placeholders in a model-authored new_string with the secrets
221
239
  * they stand for. Resolution, strictest first: if the new placeholder