agent-sanitizer 2.30.0 → 2.31.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PreToolUse content-protection orchestrator. Runs four layers in ONE process:
2
+ * PreToolUse content-protection orchestrator. Runs five layers in ONE process:
3
3
  *
4
4
  * 1. Invisible-char injection gate (lib/invisible-alert.mjs)
5
5
  * 2. Confusable/homoglyph normalization of paths & commands
@@ -8,6 +8,9 @@
8
8
  * (lib/authored-content.mjs)
9
9
  * 4. Rehydration of secret-redaction placeholders in Edit/Write inputs
10
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
11
14
  *
12
15
  * WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
13
16
  * chain their `updatedInput` — each hook sees the original input and the last to
@@ -60,7 +63,13 @@ import {
60
63
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
61
64
  import { secretsEnabled } from "./lib/env-config.mjs";
62
65
  import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
63
- import { placeholderNotice } from "./lib/placeholder-grammar.mjs";
66
+ import {
67
+ placeholderNotice,
68
+ layer2PlaceholderNotice,
69
+ layer2Keys,
70
+ LAYER2_PLACEHOLDER_RE,
71
+ } from "./lib/placeholder-grammar.mjs";
72
+ import { readSpan, spanPath } from "./lib/reveal.mjs";
64
73
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
65
74
 
66
75
  const HOOK_NAME = "pretooluse-sanitize";
@@ -110,6 +119,14 @@ const { rehydrateRedacted } =
110
119
  /** @type {typeof import("agent-sanitizer/rehydrate")} */ (
111
120
  await lazyImport("agent-sanitizer/rehydrate")
112
121
  );
122
+ // The one sound multi-needle splice primitive (see its doc in the engine): the
123
+ // Layer-2 rehydrator below substitutes every keyed placeholder in one ordered
124
+ // pass, so a stored original whose bytes happen to contain another placeholder
125
+ // is never re-matched and re-expanded.
126
+ const { spliceOrdered } =
127
+ /** @type {typeof import("agent-sanitizer/view-map")} */ (
128
+ await lazyImport("agent-sanitizer/view-map")
129
+ );
113
130
 
114
131
  // Injection seams binding the peer dependencies into the provider-agnostic
115
132
  // package functions. namespace-guard (the confusable vision map) and the
@@ -157,6 +174,130 @@ const guardedRehydrate = withSecretDropGuard(
157
174
  redactorIo,
158
175
  );
159
176
 
177
+ /**
178
+ * Substitute every keyed Layer-2 placeholder in `text` with the stored original
179
+ * from the reveal store's span files, in ONE ordered pass (spliceOrdered).
180
+ * A key with NO stored span fails CLOSED — the placeholder stands for real
181
+ * content this store cannot produce, so writing it through literally would
182
+ * silently persist the loss the keyed grammar exists to prevent.
183
+ * @param {string} text
184
+ * @param {string} field the tool-input field being rehydrated, for the deny prose
185
+ * @returns {{ text: string, restored: number } | { deny: string } | null}
186
+ */
187
+ function substituteLayer2(text, field) {
188
+ const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
189
+ text: match[0],
190
+ index: /** @type {number} */ (match.index),
191
+ key: match[1],
192
+ }));
193
+ if (matches.length === 0) return null;
194
+ /** @type {Map<string, string>} */
195
+ const byKey = new Map();
196
+ /** @type {string[]} */
197
+ const missing = [];
198
+ for (const key of new Set(matches.map((match) => match.key))) {
199
+ const stored = readSpan(key);
200
+ if (stored === null) missing.push(key);
201
+ else byKey.set(key, stored);
202
+ }
203
+ if (missing.length > 0)
204
+ return {
205
+ deny:
206
+ `${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
207
+ `in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
208
+ `${missing.map((key) => spanPath(key)).join(", ")}), so the removed content ` +
209
+ `cannot be restored automatically. Reconstruct that content yourself, ` +
210
+ `deliberately drop the placeholder(s) if the removed content should stay ` +
211
+ `removed, or ask the user to make this change`,
212
+ };
213
+ // `i` is the match's index in `matches` (stable across spliceOrdered's
214
+ // overlap skips — impossible here, distinct non-overlapping literals), so the
215
+ // key rides positionally.
216
+ const spliced = spliceOrdered(
217
+ text,
218
+ matches,
219
+ (_match, i) => /** @type {string} */ (byKey.get(matches[i].key)),
220
+ );
221
+ return { text: spliced.text, restored: matches.length };
222
+ }
223
+
224
+ /**
225
+ * Layer-2 placeholder rehydration on the write path: an Edit `new_string` or a
226
+ * Write `content` carrying `[hidden HTML removed #<key>]` / `[HTML comment
227
+ * removed #<key>]` placeholders (copied from sanitized tool output) has each
228
+ * one restored to the stored original bytes from the reveal store's span files.
229
+ *
230
+ * SECURITY invariant: the stored span content was REDACTED before persistence
231
+ * (sanitize-output runs strict web-ingress redaction on each splice original
232
+ * before persistSpan), so this rehydration can never write a raw secret — the
233
+ * worst it can restore is `[REDACTED…]` placeholder text standing where the
234
+ * secret was, which the on-disk tripwire then flags on later Reads.
235
+ *
236
+ * Composition with the secret rehydrator: this runs AFTER it (a later terminal
237
+ * layer), and the two grammars are disjoint — a Layer-2 placeholder never
238
+ * matches the `[REDACTED…]` grammar and vice versa — so neither can touch the
239
+ * other's tokens. Running second also means a restored original that contains
240
+ * `[REDACTED…]` text is never re-fed to the secret resolver (which would deny
241
+ * it as a foreign placeholder).
242
+ *
243
+ * `old_string` is deliberately NOT rehydrated: a Layer-2 placeholder there
244
+ * exists in the model's view of PRIOR TOOL OUTPUT, not on disk, so unless the
245
+ * file literally contains the placeholder text, Edit's ordinary no-match
246
+ * failure is the right outcome — no re-anchoring. MultiEdit/NotebookEdit with a
247
+ * Layer-2 placeholder are denied (parity with the secret path: sequential
248
+ * edits / notebook JSON cannot be rehydrated).
249
+ * @param {string} tool
250
+ * @param {any} toolInput
251
+ * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
252
+ */
253
+ export function rehydrateLayer2(tool, toolInput) {
254
+ const hasL2 = (/** @type {unknown} */ text) =>
255
+ typeof text === "string" && layer2Keys(text).length > 0;
256
+ if (
257
+ tool === "MultiEdit" &&
258
+ Array.isArray(toolInput?.edits) &&
259
+ toolInput.edits.some(
260
+ (/** @type {any} */ edit) =>
261
+ hasL2(edit?.old_string) || hasL2(edit?.new_string),
262
+ )
263
+ )
264
+ return {
265
+ deny:
266
+ `the edits carry [hidden HTML removed #…]/[HTML comment removed #…] ` +
267
+ `placeholders, which stand for content spliced out of earlier tool output; ` +
268
+ `MultiEdit's sequential edits cannot be rehydrated. Use single Edit calls — ` +
269
+ `each restores the stored original individually — or ask the user to make ` +
270
+ `this change`,
271
+ };
272
+ if (tool === "NotebookEdit" && hasL2(toolInput?.new_source))
273
+ return {
274
+ deny:
275
+ `new_source contains a [hidden HTML removed #…]/[HTML comment removed #…] ` +
276
+ `placeholder, which stands for content spliced out of earlier tool output; ` +
277
+ `rehydration is not supported for notebooks. Reconstruct the content, ` +
278
+ `deliberately drop the placeholder if the removed content should stay ` +
279
+ `removed, or ask the user to edit the cell`,
280
+ };
281
+ /** @type {"new_string" | "content" | null} */
282
+ const field =
283
+ tool === "Edit" && typeof toolInput?.new_string === "string"
284
+ ? "new_string"
285
+ : tool === "Write" && typeof toolInput?.content === "string"
286
+ ? "content"
287
+ : null;
288
+ if (field === null) return null;
289
+ const result = substituteLayer2(toolInput[field], field);
290
+ if (result === null) return null;
291
+ if ("deny" in result) return result;
292
+ return {
293
+ updatedInput: { ...toolInput, [field]: result.text },
294
+ context:
295
+ `${result.restored} Layer-2 removed-content placeholder(s) in ${field} ` +
296
+ `were restored to the stored original content (secrets inside were ` +
297
+ `redacted before storage, so no raw secret is written).`,
298
+ };
299
+ }
300
+
160
301
  /**
161
302
  * The wired default gates the whole rehydration layer on the secret opt-in:
162
303
  * with secrets off the output hook never inserts placeholders, so there is
@@ -263,6 +404,17 @@ export function preToolUseLayers(rehydrate, env = process.env) {
263
404
  };
264
405
  },
265
406
  },
407
+ {
408
+ name: "layer2-rehydrate",
409
+ erases: true,
410
+ skipBased: false,
411
+ // Terminal, AFTER the secret rehydrator: the grammars are disjoint (see
412
+ // rehydrateLayer2's doc), and the stored bytes it restores — which may
413
+ // legitimately contain [REDACTED…] text — must not be re-fed to the
414
+ // secret resolver or re-stripped by an earlier layer.
415
+ terminal: true,
416
+ run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
417
+ },
266
418
  ];
267
419
  return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
268
420
  ? layers.filter((layer) => layer.name !== "authored-content")
@@ -334,6 +486,11 @@ export async function buildPreToolUseResponse(
334
486
  // placeholder-shaped text is ordinary prose and the advisory is noise.
335
487
  const notice = secretsEnabled() ? placeholderNotice(tool, current) : null;
336
488
  if (notice !== null) contexts.push(notice);
489
+ // Same advisory for keyed Layer-2 splice placeholders (disjoint grammar, own
490
+ // store): a Bash/MCP write path would persist them literally too, and the
491
+ // note names the span file(s) where the original bytes live.
492
+ const layer2Notice = layer2PlaceholderNotice(tool, current);
493
+ if (layer2Notice !== null) contexts.push(layer2Notice);
337
494
 
338
495
  return emitTraced(
339
496
  emitTrace,
@@ -2,10 +2,14 @@
2
2
  * PostToolUse: sanitize tool output before the model sees it.
3
3
  *
4
4
  * Layer 1: Strip payload-capable invisible chars + ANSI escapes.
5
- * Layer 2: Splice out hidden HTML (comments, hidden-styled elements) from web
6
- * ingress; report preserved scripting/resource tags. The pre-splice
7
- * text is stashed in an ephemeral sidecar file the model may Read back
8
- * (behind an untrusted-content envelope) — see lib/reveal.mjs.
5
+ * Layer 2: Splice out hidden-styled/hidden-attribute elements and HTML
6
+ * comments from web ingress, each replaced by a keyed,
7
+ * content-addressed placeholder; report preserved scripting/resource
8
+ * tags. The pre-splice text is stashed in an ephemeral sidecar file
9
+ * the model may Read back (behind an untrusted-content envelope), and
10
+ * each splice's original is persisted beside it under the
11
+ * placeholder's key so Edit/Write can round-trip the placeholder back
12
+ * to the original bytes — see lib/reveal.mjs.
9
13
  * Layer 3: Report data-exfil-shaped URLs in web ingress (detection only).
10
14
  * Layer 4: Redact API keys/secrets via detect-secrets, served by the long-lived
11
15
  * redactor daemon — see lib/redactor-client.mjs.
@@ -39,10 +43,12 @@ import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
39
43
  import { secretsEnabled } from "./lib/env-config.mjs";
40
44
  import {
41
45
  persistReveal,
46
+ persistSpan,
47
+ SPAN_ROUNDTRIP_NOTICE,
42
48
  isRevealRead,
43
49
  REVEAL_READ_ENVELOPE,
44
50
  } from "./lib/reveal.mjs";
45
- import { containsPlaceholder } from "./lib/placeholder-grammar.mjs";
51
+ import { containsPlaceholder, layer2Keys } from "./lib/placeholder-grammar.mjs";
46
52
 
47
53
  // Layer-1 primitives and the cheap pre-gates, bound via lazyImport (see its
48
54
  // doc for the fail-OPEN hazard of a bare static npm import). A load failure
@@ -124,7 +130,7 @@ const SGR_OUTPUT_NOTE =
124
130
 
125
131
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
126
132
  // Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
127
- // model is about to edit or deletes content (comments, diffs, PR bodies, page
133
+ // model is about to edit or deletes content (diffs, PR bodies, page
128
134
  // source fetched with curl) the task legitimately needs. (MCP output gets Layer 2
129
135
  // only when HTML-shaped — see the `html` gate in sanitizeText.) Layers 1
130
136
  // (invisible chars) and 4 (secret redaction) still run on every tool.
@@ -235,13 +241,15 @@ async function redactSecrets(text, webIngress = false, deadline) {
235
241
  * the HTML rewrite (Layer 2) and the exfil-URL scan (Layer 3), the injected
236
242
  * secret redactor (Layer 4), and the display-only-SGR carve-out. `reveal` carries
237
243
  * the seam's pre-Layer-2 text when the HTML splice removed anything, for the
238
- * orchestrator to persist.
244
+ * orchestrator to persist; `splices` is its per-placeholder twin (each
245
+ * `original` already vetted by the seam's exit redaction, withheld entries
246
+ * dropped there), for the orchestrator's per-key span persistence.
239
247
  * @param {string} text
240
248
  * @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
241
249
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
242
250
  * all leaves of one hook run; a direct caller gets a fresh full budget
243
251
  * @param {SanitizeExtensions} [ext]
244
- * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
252
+ * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>}
245
253
  */
246
254
  export async function sanitizeText(
247
255
  text,
@@ -298,7 +306,7 @@ export async function sanitizeText(
298
306
  },
299
307
  };
300
308
  const seamResult =
301
- /** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
309
+ /** @type {{ cleaned: string, warnings: string[], notes?: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} */ (
302
310
  await sanitizeTextSeam(text, seamOptions)
303
311
  );
304
312
  // The one place the seam's shape is normalized: `notes` is absent when the
@@ -326,9 +334,9 @@ export async function sanitizeText(
326
334
  * notes, which would be a false account of bytes a callback has since rewritten
327
335
  * for its own reasons. A callback's `warning` is taken at face value as a
328
336
  * WARNING — the composer that linked it owns its wording and its volume alike.
329
- * @param {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
337
+ * @param {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }} result
330
338
  * @param {{ cleaned?: string, warning?: string } | null | undefined} post
331
- * @returns {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
339
+ * @returns {{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }}
332
340
  */
333
341
  function applyPostText(result, post) {
334
342
  if (post === null || post === undefined) return result;
@@ -359,6 +367,9 @@ function applyPostText(result, post) {
359
367
  * `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
360
368
  * removed something) for the orchestrator to persist, and `notes` the leaves'
361
369
  * NOTE-severity findings — same mutated-accumulator shape as `warnings`.
370
+ * `splices` accumulates each leaf's Layer-2 placeholder→original pairs (the
371
+ * per-key twin of `reveals`, already vetted by the seam) for the orchestrator's
372
+ * span persistence — same mutated-accumulator shape again.
362
373
  * @param {any} value
363
374
  * @param {string} toolName
364
375
  * @param {string[]} warnings
@@ -368,6 +379,8 @@ function applyPostText(result, post) {
368
379
  * @param {SanitizeExtensions} [ext]
369
380
  * @param {string[]} [notes] appended last so an existing caller's positional
370
381
  * arguments keep their meaning
382
+ * @param {Array<{ placeholder: string, original: string }>} [splices] appended
383
+ * after `notes` for the same positional-compatibility reason
371
384
  * @param {string} [path] dotted location of `value` within the tool output,
372
385
  * used only to name a key collision's location in its warning
373
386
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
@@ -380,6 +393,7 @@ export async function sanitizeValue(
380
393
  deadline = makeDeadline(SANITIZE_BUDGET_MS),
381
394
  ext = {},
382
395
  notes = [],
396
+ splices = [],
383
397
  path = "",
384
398
  ) {
385
399
  if (typeof value === "string") {
@@ -387,6 +401,7 @@ export async function sanitizeValue(
387
401
  warnings.push(...result.warnings);
388
402
  notes.push(...result.notes);
389
403
  if (result.reveal !== undefined) reveals.push(result.reveal);
404
+ if (result.splices !== undefined) splices.push(...result.splices);
390
405
  return {
391
406
  value: result.cleaned,
392
407
  modified: result.modified,
@@ -406,6 +421,7 @@ export async function sanitizeValue(
406
421
  deadline,
407
422
  ext,
408
423
  notes,
424
+ splices,
409
425
  `${path}[${index}]`,
410
426
  );
411
427
  out.push(result.value);
@@ -423,6 +439,7 @@ export async function sanitizeValue(
423
439
  deadline,
424
440
  ext,
425
441
  notes,
442
+ splices,
426
443
  path,
427
444
  );
428
445
  return { value, modified: false, sgrNote: false };
@@ -504,6 +521,8 @@ function withheldKeyFor(out, cleaned) {
504
521
  * @param {{remainingMs: () => number}} deadline shared wall-clock budget
505
522
  * @param {SanitizeExtensions} ext
506
523
  * @param {string[]} notes accumulates the leaves' NOTE-severity findings
524
+ * @param {Array<{ placeholder: string, original: string }>} splices accumulates
525
+ * the leaves' Layer-2 placeholder→original pairs
507
526
  * @param {string} [path] dotted location of this object in the tool output
508
527
  * @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
509
528
  */
@@ -515,6 +534,7 @@ async function sanitizeObject(
515
534
  deadline,
516
535
  ext,
517
536
  notes,
537
+ splices,
518
538
  path = "",
519
539
  ) {
520
540
  /** @type {Record<string, any>} */
@@ -534,6 +554,7 @@ async function sanitizeObject(
534
554
  warnings.push(...keyResult.warnings);
535
555
  notes.push(...keyResult.notes);
536
556
  if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
557
+ if (keyResult.splices !== undefined) splices.push(...keyResult.splices);
537
558
  if (keyResult.modified) modified = true;
538
559
  if (keyResult.sgrNote) sgrNote = true;
539
560
  const result = await sanitizeValue(
@@ -544,6 +565,7 @@ async function sanitizeObject(
544
565
  deadline,
545
566
  ext,
546
567
  notes,
568
+ splices,
547
569
  path === "" ? keyResult.cleaned : `${path}.${keyResult.cleaned}`,
548
570
  );
549
571
  // Two distinct raw keys can sanitize to the same name (e.g. `token` and a
@@ -864,9 +886,11 @@ export async function evaluateToolOutput(input, ext = {}) {
864
886
  const notes = [];
865
887
  /** @type {string[]} */
866
888
  const reveals = [];
889
+ /** @type {Array<{ placeholder: string, original: string }>} */
890
+ const splices = [];
867
891
  // One shared wall-clock budget for every blocking daemon call this hook makes —
868
- // across all leaves of the walk AND the reveal-redaction loop below — so their
869
- // SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
892
+ // across all leaves of the walk AND the reveal/span-redaction loops below — so
893
+ // their SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
870
894
  const deadline = makeDeadline(SANITIZE_BUDGET_MS);
871
895
  const {
872
896
  value: sanitized,
@@ -880,13 +904,14 @@ export async function evaluateToolOutput(input, ext = {}) {
880
904
  deadline,
881
905
  ext,
882
906
  notes,
907
+ splices,
883
908
  );
884
909
  // Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
885
910
  // Read back what the HTML splice removed; a successful write appends a hint
886
911
  // naming the file. Redact BEFORE writing — never put an unredacted secret on
887
- // disk, including one hidden inside the spliced comment itself. Reveals only
888
- // arise when Layer 2 modified the output, so this never resurrects the `clean`
889
- // early-return below.
912
+ // disk, including one carried inside the spliced hidden element itself.
913
+ // Reveals only arise when Layer 2 modified the output, so this never
914
+ // resurrects the `clean` early-return below.
890
915
  for (const original of reveals) {
891
916
  let stored;
892
917
  try {
@@ -897,19 +922,47 @@ export async function evaluateToolOutput(input, ext = {}) {
897
922
  : null;
898
923
  stored = secrets ? secrets.text : original;
899
924
  } catch {
900
- // The pre-splice text carries the spliced comment bodies, so a secret
901
- // hidden only inside a comment reaches the redactor here for the first
902
- // time (the post-splice scan never saw it). If the daemon is unreachable
903
- // we must neither write that unvetted text nor suppress the already-safe
904
- // primary output — drop this one convenience reveal, but SAY so: the
905
- // splice warning has just promised the model a reveal it can Read back,
906
- // and a silent drop leaves that promise dangling.
925
+ // The pre-splice text carries the spliced hidden-element content, so a
926
+ // secret hidden only inside such an element reaches the redactor here
927
+ // for the first time (the post-splice scan never saw it). If the daemon
928
+ // is unreachable we must neither write that unvetted text nor suppress
929
+ // the already-safe primary output — drop this one convenience reveal,
930
+ // but SAY so: the splice warning has just promised the model a reveal it
931
+ // can Read back, and a silent drop leaves that promise dangling.
907
932
  warnings.push(REVEAL_WITHHELD_WARNING);
908
933
  continue;
909
934
  }
910
935
  const hint = persistReveal(stored);
911
936
  if (hint) warnings.push(hint);
912
937
  }
938
+ // Persist each splice's original beside the reveal, keyed by the placeholder's
939
+ // content-addressed key, so the PreToolUse rehydrator can restore a keyed
940
+ // placeholder the model writes back (Edit/Write) to the original bytes. Same
941
+ // re-redaction treatment as the reveals above — the seam already vetted each
942
+ // `original` on its way out, but this loop re-runs strict web-ingress
943
+ // redaction so NOTHING lands on disk that did not pass the same bar as the
944
+ // reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
945
+ // from the redacted original: it was minted from the RAW original's sha256,
946
+ // so it is a name, not an integrity check — hashing the redacted bytes would
947
+ // mint a key no placeholder carries. Persistence failure is non-fatal (the
948
+ // splice already protected the output); a missing span later fails the
949
+ // rehydration CLOSED with a deny naming the key.
950
+ let spanStored = false;
951
+ for (const { placeholder, original } of splices) {
952
+ const [key] = layer2Keys(placeholder);
953
+ if (key === undefined) continue;
954
+ let stored;
955
+ try {
956
+ const secrets = await redactSecrets(original, true, deadline);
957
+ stored = secrets ? secrets.text : original;
958
+ } catch {
959
+ // Same doctrine as the reveal loop: never write unvetted text, never
960
+ // fail the already-safe primary output over a convenience sidecar.
961
+ continue;
962
+ }
963
+ if (persistSpan(key, stored)) spanStored = true;
964
+ }
965
+ if (spanStored) warnings.push(SPAN_ROUNDTRIP_NOTICE);
913
966
  // On-disk placeholder tripwire (see ON_DISK_PLACEHOLDER_WARNING). Tested on
914
967
  // the RAW tool_response — post-sanitization text carries placeholders this
915
968
  // hook itself just inserted. Reads only: file bytes are where a clobbered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.30.0",
3
+ "version": "2.31.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/gates.mjs CHANGED
@@ -15,9 +15,11 @@
15
15
  * Matches any HTML tag-like construct: opening tags, closing tags (`</`),
16
16
  * comments and bogus declarations (`<!`), and processing instructions / bogus
17
17
  * comments (`<?…?>`, which the HTML tokenizer hides exactly like a comment).
18
- * The `<?` arm is what lets a PI-only document reach Layer 2's bogus-comment
19
- * splice; without it such a document would skip the pipeline entirely. Gate for
20
- * Layer 2 (HTML sanitization) and the HTML img/a exfil path in Layer 3.
18
+ * The `<!`/`<?` arms carry a comment-only document into the pipeline at all:
19
+ * without them it would skip both Layer 2's splice of the comment and Layer 3's
20
+ * exfil scan over the comment interior.
21
+ * Gate for Layer 2 (HTML sanitization) and the HTML img/a exfil path in
22
+ * Layer 3.
21
23
  */
22
24
  export const HTML_TAG_PRESENT = /<[a-zA-Z/!?][^<>]*>/;
23
25