agent-sanitizer 2.0.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.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * Edit-repair: re-anchor an Edit/Write composed from a sanitized file view back
3
+ * onto the real on-disk bytes.
4
+ *
5
+ * Sanitizing the model's view of a file makes that view diverge from disk in
6
+ * two ways: Layer 1 strips ANSI escapes and payload-capable invisible
7
+ * characters, and secret redaction replaces secrets with [REDACTED…]
8
+ * placeholders. An Edit whose old_string was copied from that view then fails
9
+ * exact-match against the real file, and a whole-file Write would persist
10
+ * placeholder text over the real secret. This module closes the loop without
11
+ * ever showing the model a secret: it re-derives the sanitized view of the
12
+ * target file (the shared {@link applyLayer1}, then the injected redactor's
13
+ * map mode), locates the model's old_string in that view, and maps it
14
+ * span-exact back to the on-disk bytes — across both placeholder expansion and
15
+ * stripped invisible runs (the offset machinery lives in `./view-map.mjs`).
16
+ * Placeholders in new_string are substituted with the secrets they stand for;
17
+ * invisible characters inside the replaced region go with it, while runs
18
+ * outside the span are preserved untouched. The secret flows disk → tool input
19
+ * only; the model's next view is sanitized again.
20
+ *
21
+ * Security invariant: rehydration must never *expose a secret this call
22
+ * rehydrated*. Before rewriting, the would-be post-edit content is
23
+ * re-sanitized and the call is denied if any secret THIS EDIT resolved from a
24
+ * placeholder would survive in the model's next view of the file (e.g. an
25
+ * edit whose old_string/new_string carries a `[REDACTED…]` placeholder and
26
+ * relabels `password=` to a field the redactor skips).
27
+ *
28
+ * Scope: this check only runs for edits that touch a placeholder. An edit
29
+ * that relabels a field WITHOUT altering its placeholder or value at all
30
+ * (e.g. old_string: "password=", new_string: "notes=" — neither string
31
+ * contains a placeholder) never reaches the exposure simulation; see the
32
+ * early-exit comments near `rehydrateEdit`'s "span is byte-identical" check
33
+ * and `rehydrateRedacted`'s "hint-free, view matches disk" check below. That
34
+ * gap is an accepted scope limit, not an oversight: simulating full-file
35
+ * exposure on every relabel-adjacent edit would re-run redaction over the
36
+ * whole file on every Edit call, and a broader check risks false denials on a
37
+ * legitimate relabel in a large file — this module's fail-open-on-ambiguity
38
+ * doctrine prefers the false negative there. Catching a bare relabel (no
39
+ * placeholder touched) is the redactor's own field-name heuristics' job, if
40
+ * it has any — not this module's. Every unresolvable case this module DOES
41
+ * cover fails closed as a deny whose reason tells the model how to
42
+ * restructure the call; nothing this module rehydrates is ever silently
43
+ * written with placeholder text standing in for a secret.
44
+ *
45
+ * I/O is INJECTED through `io`: the caller supplies file reads and the secret
46
+ * redactor (its map/plain contract). The package never bundles a redactor —
47
+ * detect-secrets, a daemon, or any other engine is the caller's to wire.
48
+ */
49
+ import { applyLayer1, LONE_SURROGATE_RE } from "./layer1.mjs";
50
+ import {
51
+ occurrences,
52
+ overlapAwareCount,
53
+ orderedMatches,
54
+ alignDeletions,
55
+ resolveSpan,
56
+ rehydrateNewString,
57
+ pairsToUtf16,
58
+ pairDiskSpans,
59
+ } from "./view-map.mjs";
60
+
61
+ // Cheap gate: every redaction placeholder the canonical redactor emits starts
62
+ // with this. A caller whose placeholders differ overrides it via the `hint`
63
+ // option below.
64
+ export const DEFAULT_HINT = "[REDACTED";
65
+
66
+ /**
67
+ * Map-mode response from the redactor: either the mappable view (text + ordered
68
+ * (placeholder, original, start) pairs) or an unmappable verdict carrying its
69
+ * reason — a discriminated pair.
70
+ * @typedef {{text: string, pairs: {placeholder: string, original: string, start: number}[]}
71
+ * | {unmappable: string}} RedactMapView
72
+ */
73
+
74
+ /**
75
+ * Injected I/O. `readFile` returns the file's bytes (throwing on a missing or
76
+ * unreadable path). `redactMap` returns the redacted view of (Layer-1-cleaned)
77
+ * file text plus the ordered (placeholder, original, start) pairs, or an
78
+ * `{unmappable}` verdict. `redact` returns the plain redacted text, or null
79
+ * when nothing was redacted. `redactMap`/`redact` are the only secret-engine
80
+ * seam; they may be async and are awaited.
81
+ * @typedef {{ readFile: (path: string) => string,
82
+ * redactMap: (text: string) => Promise<RedactMapView> | RedactMapView,
83
+ * redact: (text: string) => Promise<string|null> | (string|null) }} RehydrateIo
84
+ */
85
+
86
+ /**
87
+ * Layer 1, then the same lone-surrogate normalization `output.mjs`'s
88
+ * `processLayer1` applies before any further layer (including redaction)
89
+ * runs — so text handed to the redactor here, and matched against the
90
+ * model's old_string, is byte-identical to what the model was actually
91
+ * shown. `layer1Cleaned` (pre-normalization) is also returned: callers that
92
+ * need `alignDeletions` require a true subsequence of the original text, and
93
+ * the normalization is a same-length SUBSTITUTION (one lone-surrogate UTF-16
94
+ * unit -> one U+FFFD unit), not a deletion — folding it into the deletion
95
+ * calculation would break that subsequence invariant. Because the
96
+ * substitution never changes length, deletions computed against
97
+ * `layer1Cleaned` stay position-valid against `cleaned`.
98
+ * @param {string} text
99
+ * @returns {{layer1Cleaned: string, cleaned: string}}
100
+ */
101
+ function layer1View(text) {
102
+ const { cleaned: layer1Cleaned } = applyLayer1(text);
103
+ return {
104
+ layer1Cleaned,
105
+ cleaned: layer1Cleaned.replace(LONE_SURROGATE_RE, "\uFFFD"),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Count of secrets the model's *next* sanitized view of `newContent` would
111
+ * reveal, excluding any already visible in the prior view (no regression
112
+ * there). The next view is Layer 1 (+ lone-surrogate normalization) then
113
+ * redaction, exactly as a PostToolUse sanitizer derives it.
114
+ * @param {string[]} secrets rehydrated values written into newContent
115
+ * @param {string} priorView sanitized view of the file before the change
116
+ * @param {string} newContent would-be post-change file content
117
+ * @param {RehydrateIo} io
118
+ * @returns {Promise<number>}
119
+ */
120
+ async function exposedSecrets(secrets, priorView, newContent, io) {
121
+ const candidates = [...new Set(secrets)].filter(
122
+ (value) => !priorView.includes(value),
123
+ );
124
+ if (candidates.length === 0) return 0;
125
+ const { cleaned } = layer1View(newContent);
126
+ const redacted = (await io.redact(cleaned)) ?? cleaned;
127
+ return candidates.filter((value) => redacted.includes(value)).length;
128
+ }
129
+
130
+ /** @param {number} count */
131
+ function exposureDeny(count) {
132
+ return (
133
+ `this change would move ${count} secret value(s) into a context the redactor no ` +
134
+ `longer recognizes, so the next read of the file would reveal them; keep each ` +
135
+ `secret under its recognizable field name, or ask the user to make this change`
136
+ );
137
+ }
138
+
139
+ /**
140
+ * @param {{file_path: string, old_string: string, new_string: string, replace_all?: boolean}} ti
141
+ * @param {string} content disk bytes
142
+ * @param {string} cleaned Layer-1 view of `content`
143
+ * @param {{text: string, pairs: {placeholder: string, original: string, start: number}[]}} view
144
+ * @param {{start: number, deleted: string}[]} deletions
145
+ * @param {RehydrateIo} io
146
+ * @param {boolean} hinted the input itself carries placeholders
147
+ * @param {string} hint placeholder prefix
148
+ */
149
+ async function rehydrateEdit(
150
+ ti,
151
+ content,
152
+ cleaned,
153
+ view,
154
+ deletions,
155
+ io,
156
+ hinted,
157
+ hint,
158
+ ) {
159
+ const oldS = ti.old_string;
160
+ // An empty old_string is not a view span the model copied — in real Edit it
161
+ // is the create/insert-at-anchor case, which Edit handles itself. There is
162
+ // nothing to re-anchor; pass through (null) so Edit surfaces its own
163
+ // behavior and the empty needle never reaches occurrences.
164
+ if (oldS === "") return null;
165
+ // Resolve against the VIEW first — it is the only thing the model can have
166
+ // copied from. A verbatim disk match is only trusted when the view has no
167
+ // match: on a divergent file, raw bytes can contain an accidental match
168
+ // spanning a stripped sequence's tail, which would mis-anchor the edit.
169
+ const viewOcc = occurrences(view.text, oldS);
170
+ if (viewOcc.length === 0) {
171
+ // Not in the model's view. A verbatim disk match means the input targets
172
+ // literal bytes (e.g. literal "[REDACTED]" prose); new_string still goes
173
+ // through the resolver (with an empty span) so a placeholder referencing a
174
+ // secret elsewhere in the file is denied with guidance instead of being
175
+ // written out literally.
176
+ if (content.includes(oldS)) {
177
+ // R1: the old_string is invisible in the model's view yet matches disk.
178
+ // If a disk match cuts INTO a redacted secret's on-disk span without
179
+ // covering the whole secret, the model is targeting bytes it never saw —
180
+ // a stray match, or a probe (`old:"-" → "\n-"`) that splits the secret so
181
+ // the next redaction pass stops matching it, leaking it. That is never a
182
+ // legitimate re-anchor; fail closed. A match that WHOLLY contains a secret
183
+ // (the model supplied the secret's real bytes itself, e.g. a rotation)
184
+ // extracts nothing and is left to the literal resolver below.
185
+ const diskSpans = pairDiskSpans(view, deletions);
186
+ const intrudes = occurrences(content, oldS).some((matchStart) => {
187
+ const matchEnd = matchStart + oldS.length;
188
+ return diskSpans.some(
189
+ (secret) =>
190
+ matchStart < secret.end &&
191
+ secret.start < matchEnd &&
192
+ !(matchStart <= secret.start && secret.end <= matchEnd),
193
+ );
194
+ });
195
+ if (intrudes)
196
+ return {
197
+ deny:
198
+ `old_string matches bytes inside a ${hint}…] redacted secret in ` +
199
+ `${ti.file_path} that are hidden from your view; edit only text you can ` +
200
+ `see (include each placeholder whole), or ask the user to make this change`,
201
+ };
202
+ const literalRes = rehydrateNewString(
203
+ oldS,
204
+ ti.new_string,
205
+ [],
206
+ view.pairs,
207
+ );
208
+ return "deny" in literalRes ? literalRes : null;
209
+ }
210
+ // Without placeholders this is an ordinary stale/typo'd old_string; pass
211
+ // through so the model gets Edit's familiar not-found error.
212
+ if (!hinted) return null;
213
+ return {
214
+ deny:
215
+ `old_string contains ${hint}…] placeholders but does not match the sanitized ` +
216
+ `view of ${ti.file_path}; re-read the file and copy the placeholder text exactly`,
217
+ };
218
+ }
219
+ // R5: `occurrences` steps by the needle length, so a self-overlapping
220
+ // old_string ("aa" in "aaa") reports a single match and would slip past the
221
+ // >1 gate — yet it has multiple anchors the view can differ from disk at.
222
+ // Count with overlap awareness so the ambiguity is caught.
223
+ const viewMatchCount = overlapAwareCount(view.text, oldS);
224
+ if (viewMatchCount > 1 && !ti.replace_all)
225
+ return {
226
+ deny:
227
+ `old_string matches ${viewMatchCount} locations in the sanitized view of ` +
228
+ `${ti.file_path}, and the view can differ from disk at each (redacted ` +
229
+ `secrets, stripped invisible characters); add surrounding context to make it unique`,
230
+ };
231
+
232
+ const spans = [];
233
+ for (const start of viewOcc) {
234
+ const resolved = resolveSpan(
235
+ content,
236
+ cleaned,
237
+ view,
238
+ deletions,
239
+ start,
240
+ start + oldS.length,
241
+ );
242
+ if (resolved === null)
243
+ return {
244
+ deny: `old_string starts or ends inside a ${hint}…] placeholder; include each placeholder whole`,
245
+ };
246
+ spans.push(resolved);
247
+ }
248
+ if (new Set(spans.map((span) => span.diskText)).size > 1)
249
+ return {
250
+ deny:
251
+ `replace_all matched occurrences whose on-disk bytes differ (distinct secrets ` +
252
+ `or invisible characters) in ${ti.file_path}; edit each occurrence separately ` +
253
+ `with unique context`,
254
+ };
255
+
256
+ // Identical view spans hide identical disk text, so every span carries the
257
+ // same placeholder/original sequence — resolve new_string against the first.
258
+ const span = spans[0];
259
+ // R2: replace_all rewrites EVERY on-disk occurrence of the resolved bytes, but
260
+ // only the sanitized-view occurrences were vetted. Each view occurrence maps to
261
+ // exactly one disk occurrence, so a larger disk count means extra matches exist
262
+ // where the view can't show them — inside a redacted secret's on-disk span, or
263
+ // a stripped run. real Edit would splice those hidden bytes too (splitting a
264
+ // secret so the redactor stops matching it, or corrupting it); fail closed.
265
+ const diskMatchCount = occurrences(content, span.diskText).length;
266
+ if (ti.replace_all && diskMatchCount !== viewOcc.length)
267
+ return {
268
+ deny:
269
+ `replace_all would rewrite ${diskMatchCount} on-disk occurrence(s) of the matched ` +
270
+ `text but only ${viewOcc.length} are visible in the sanitized view of ` +
271
+ `${ti.file_path}; the rest are hidden inside redacted secrets or stripped ` +
272
+ `characters. Edit each visible occurrence separately with unique context, or ask ` +
273
+ `the user to make this change`,
274
+ };
275
+ // Soundness gate (see resolveSpan): greedy deletion alignment can anchor a
276
+ // view span to the wrong disk bytes when a stripped run abuts kept text it
277
+ // resembles. Refuse on either symptom:
278
+ // (a) the resolved bytes do not re-clean to the span's view — the run stole
279
+ // a visible character (an ANSI sequence ending in "m" before a kept "m");
280
+ // (b) the bytes carry an interior stripped run yet the plain old_string also
281
+ // exists verbatim on disk — a purely-invisible collision (e.g. a
282
+ // zero-width char inside an otherwise-identical run) re-cleans cleanly,
283
+ // so (a) misses it, but a verbatim clean occurrence means the model's
284
+ // text could equally well anchor there. Either way the anchor is
285
+ // ambiguous; fail closed rather than edit the wrong region.
286
+ const anchorAmbiguous =
287
+ layer1View(span.diskText).cleaned !== span.cleanedText ||
288
+ (span.diskText !== oldS && content.includes(oldS));
289
+ if (anchorAmbiguous)
290
+ return {
291
+ deny:
292
+ `the matched region sits next to stripped control sequences that cannot be ` +
293
+ `re-anchored unambiguously in ${ti.file_path}; edit a smaller region away ` +
294
+ `from them, or ask the user to make this change`,
295
+ };
296
+ const newRes = rehydrateNewString(
297
+ oldS,
298
+ ti.new_string,
299
+ span.pairs,
300
+ view.pairs,
301
+ );
302
+ if ("deny" in newRes) return newRes;
303
+
304
+ // The span is byte-identical to disk (no pairs, no interior runs): nothing
305
+ // to translate. The empty-span resolver above already vetted new_string.
306
+ // No placeholder was touched, so this exit also skips the exposure
307
+ // simulation below — in scope per the module doc's "Security invariant"
308
+ // note: a relabel that never names a placeholder is an accepted gap, not
309
+ // covered here.
310
+ if (span.diskText === oldS && newRes.text === ti.new_string) return null;
311
+
312
+ // Simulate the post-edit content for the exposure check. When the disk
313
+ // old_string is not unique and replace_all is off, Edit itself will refuse
314
+ // the call, so nothing is written and there is nothing to check.
315
+ const diskOcc = occurrences(content, span.diskText);
316
+ let updated = null;
317
+ if (ti.replace_all) updated = content.split(span.diskText).join(newRes.text);
318
+ else if (diskOcc.length === 1)
319
+ updated =
320
+ content.slice(0, diskOcc[0]) +
321
+ newRes.text +
322
+ content.slice(diskOcc[0] + span.diskText.length);
323
+ if (updated !== null) {
324
+ const exposed = await exposedSecrets(
325
+ newRes.secrets,
326
+ view.text,
327
+ updated,
328
+ io,
329
+ );
330
+ if (exposed > 0) return { deny: exposureDeny(exposed) };
331
+ }
332
+
333
+ const notes = [
334
+ span.pairs.length > 0 &&
335
+ `${hint}…] placeholders were resolved to the file's real secret values (still hidden from you)`,
336
+ span.invisibleBytes > 0 &&
337
+ `the matched region carries ${span.invisibleBytes} invisible/control character(s) stripped from your view; they are replaced along with it`,
338
+ ].filter(Boolean);
339
+ return {
340
+ updatedInput: { ...ti, old_string: span.diskText, new_string: newRes.text },
341
+ context: `Edit input was translated to the file's actual on-disk bytes: ${notes.join("; ")}.`,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Foreign redaction placeholders surviving in post-substitution content `out`:
347
+ * hint-prefixed, placeholder-shaped tokens that are neither introduced by a
348
+ * substituted secret (they fall inside `secretSpans`) nor already present
349
+ * verbatim in the file's own sanitized `viewText` (a genuine same-file
350
+ * placeholder, or literal prose like `[REDACTEDXYZ]` that merely shares the hint
351
+ * prefix). A non-empty result means the Write pasted a `[REDACTED…]` placeholder
352
+ * from another file or context that would be persisted verbatim in place of a
353
+ * real secret. Comparing the actual token strings — not scalar hint counts —
354
+ * catches a count-offsetting edit (drop one literal hint, add one foreign
355
+ * placeholder) that a scalar `>` gate lets through.
356
+ * @param {string} out post-substitution content
357
+ * @param {string} hint placeholder prefix
358
+ * @param {string} viewText the file's own sanitized view
359
+ * @param {{start: number, end: number}[]} secretSpans byte ranges of substituted secrets in `out`
360
+ * @returns {string[]}
361
+ */
362
+ function foreignPlaceholders(out, hint, viewText, secretSpans) {
363
+ const foreign = [];
364
+ for (const start of occurrences(out, hint)) {
365
+ if (secretSpans.some((span) => span.start <= start && start < span.end))
366
+ continue;
367
+ // Extend the token to the placeholder's closing "]"; a hint with no closing
368
+ // bracket is malformed, so treat the rest of the string as its text and let
369
+ // the same-view check below decide (an unclosed hint absent from the view
370
+ // is foreign, failing closed).
371
+ const close = out.indexOf("]", start + hint.length);
372
+ const token = close === -1 ? out.slice(start) : out.slice(start, close + 1);
373
+ // Genuine same-file text: the exact token already exists in the file's own
374
+ // sanitized view (an own placeholder, or hint-prefixed prose it documents).
375
+ if (viewText.includes(token)) continue;
376
+ foreign.push(token);
377
+ }
378
+ return foreign;
379
+ }
380
+
381
+ /**
382
+ * @param {{file_path: string, content: string}} ti
383
+ * @param {{text: string, pairs: {placeholder: string, original: string, start: number}[]}} view
384
+ * @param {RehydrateIo} io
385
+ * @param {string} hint placeholder prefix
386
+ */
387
+ async function rehydrateWrite(ti, view, io, hint) {
388
+ const texts = [...new Set(view.pairs.map((pair) => pair.placeholder))].filter(
389
+ (phText) => ti.content.includes(phText),
390
+ );
391
+ // None of THIS file's redaction placeholders appear in the new content.
392
+ // isCandidate already guaranteed ti.content contains the hint prefix (e.g.
393
+ // "[REDACTED"), so an empty `texts` here means the content carries a
394
+ // placeholder-shaped string that names a secret from a DIFFERENT file or
395
+ // context (or a stale/mistyped one) — not literal prose. Persisting it
396
+ // verbatim would silently write "[REDACTED:…]" into the file where the
397
+ // model likely intended an actual secret value; deny instead.
398
+ if (texts.length === 0)
399
+ return {
400
+ deny:
401
+ `the ${hint}…] placeholder in the new content does not match any secret in ` +
402
+ `${ti.file_path}, so a whole-file Write cannot copy a placeholder from another ` +
403
+ `file or context; request the source file's content and rehydrate a same-file ` +
404
+ `Edit instead, or write the secret's real value directly`,
405
+ };
406
+
407
+ // Resolve each of this file's placeholder texts to its single secret first,
408
+ // then splice in ONE ordered pass (R6). A chained `out.split(ph).join(secret)`
409
+ // per placeholder is unsound: an inserted secret whose bytes contain a later
410
+ // placeholder text would be re-matched and corrupted by the next split.
411
+ const valueByPh = new Map();
412
+ for (const phText of texts) {
413
+ const produced = view.pairs.filter((pair) => pair.placeholder === phText);
414
+ if (occurrences(view.text, phText).length > produced.length)
415
+ return {
416
+ deny:
417
+ `${ti.file_path} mixes literal "${phText}" text with a redacted secret sharing ` +
418
+ `that placeholder; cannot tell which occurrences in the new content are ` +
419
+ `which — use Edit with unique surrounding context instead`,
420
+ };
421
+ const values = [...new Set(produced.map((pair) => pair.original))];
422
+ if (values.length > 1)
423
+ return {
424
+ deny:
425
+ `multiple distinct secrets in ${ti.file_path} share the placeholder "${phText}", ` +
426
+ `so a whole-file Write cannot tell which is which; use Edit with unique ` +
427
+ `surrounding context for each`,
428
+ };
429
+ valueByPh.set(phText, values[0]);
430
+ }
431
+ const matches = orderedMatches(ti.content, texts);
432
+ let out = "";
433
+ let last = 0;
434
+ // Byte ranges in `out` occupied by the substituted secret values. A hint
435
+ // occurrence inside one of these is a pathological secret whose bytes contain
436
+ // the hint prefix, NOT a placeholder the model pasted — so it is excluded from
437
+ // the foreign-placeholder scan below.
438
+ const secretSpans = [];
439
+ for (const match of matches) {
440
+ const secret = valueByPh.get(match.text);
441
+ out += ti.content.slice(last, match.index);
442
+ const secretStart = out.length;
443
+ out += secret;
444
+ secretSpans.push({ start: secretStart, end: out.length });
445
+ last = match.index + match.text.length;
446
+ }
447
+ out += ti.content.slice(last);
448
+ const secrets = [...valueByPh.values()];
449
+
450
+ // R3: the new content may mix a valid same-file placeholder (substituted
451
+ // above) with a FOREIGN one — a placeholder pasted from another file/context
452
+ // that shares the hint prefix but is not one of this file's own. Those were
453
+ // left untouched and would be persisted verbatim over a real secret. Compare
454
+ // the ACTUAL placeholder STRINGS, not scalar hint counts: a scalar comparison
455
+ // is defeated by an edit that drops one literal hint and adds one foreign
456
+ // placeholder (the counts net to zero), which would then persist the foreign
457
+ // placeholder. Deny when any genuinely-foreign placeholder survives.
458
+ if (foreignPlaceholders(out, hint, view.text, secretSpans).length > 0)
459
+ return {
460
+ deny:
461
+ `the new content still carries a ${hint}…] placeholder that does not match any ` +
462
+ `secret in ${ti.file_path}, so a whole-file Write cannot copy a placeholder from ` +
463
+ `another file or context; request the source file's content and rehydrate a ` +
464
+ `same-file Edit instead, or write the secret's real value directly`,
465
+ };
466
+
467
+ const exposed = await exposedSecrets(secrets, view.text, out, io);
468
+ if (exposed > 0) return { deny: exposureDeny(exposed) };
469
+
470
+ return {
471
+ updatedInput: { ...ti, content: out },
472
+ context:
473
+ `Write content contained ${hint}…] placeholders; they were resolved to the ` +
474
+ `file's real secret values on disk (still hidden from you), so the secrets ` +
475
+ `are preserved in the written file.`,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * True when this tool call could need re-anchoring against the target file's
481
+ * sanitized view: any well-formed Edit (the view may differ from disk even
482
+ * without placeholders, via stripped invisible characters), or a Write whose
483
+ * content carries a placeholder.
484
+ * @param {string} tool
485
+ * @param {any} ti
486
+ * @param {string} hint
487
+ */
488
+ function isCandidate(tool, ti, hint) {
489
+ if (typeof ti?.file_path !== "string") return false;
490
+ if (tool === "Edit")
491
+ return (
492
+ typeof ti.old_string === "string" && typeof ti.new_string === "string"
493
+ );
494
+ if (tool === "Write")
495
+ return typeof ti.content === "string" && ti.content.includes(hint);
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Re-anchor an Edit/Write input composed from a sanitized file view back onto
501
+ * the on-disk bytes (secrets rehydrated, stripped invisible runs re-attached).
502
+ * Returns the rewritten input plus a model-facing context line, a deny with an
503
+ * instructive reason when the input is unresolvable or would expose a secret,
504
+ * or null when there is nothing to do. Throws only on internal error (the
505
+ * caller fails closed).
506
+ *
507
+ * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
508
+ * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
509
+ * only if the injected redactor emits a different placeholder shape.
510
+ * @param {string} tool
511
+ * @param {any} toolInput
512
+ * @param {RehydrateIo} io
513
+ * @param {{ hint?: string }} [options]
514
+ * @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
515
+ */
516
+ export async function rehydrateRedacted(
517
+ tool,
518
+ toolInput,
519
+ io,
520
+ { hint = DEFAULT_HINT } = {},
521
+ ) {
522
+ // A notebook cell carrying a placeholder would persist it verbatim over the
523
+ // secret; mapping .ipynb JSON is not supported, so refuse with guidance.
524
+ if (
525
+ tool === "NotebookEdit" &&
526
+ typeof toolInput?.new_source === "string" &&
527
+ toolInput.new_source.includes(hint)
528
+ )
529
+ return {
530
+ deny:
531
+ `new_source contains a ${hint}…] placeholder, which stands for a secret ` +
532
+ `hidden from your view; rehydration is not supported for notebooks. Keep ` +
533
+ `the secret-bearing cell unchanged, or ask the user to edit it.`,
534
+ };
535
+ if (!isCandidate(tool, toolInput, hint)) return null;
536
+ const hinted =
537
+ tool === "Write" ||
538
+ toolInput.old_string.includes(hint) ||
539
+ toolInput.new_string.includes(hint);
540
+
541
+ let content;
542
+ try {
543
+ content = io.readFile(toolInput.file_path);
544
+ } catch (err) {
545
+ // The catch binding is `unknown` under strict TS; io's contract only
546
+ // promises Node-shaped read failures (a real `readFile`'s throw), so
547
+ // narrow once here rather than re-deriving the cast at every use below.
548
+ const nodeErr = /** @type {NodeJS.ErrnoException} */ (err);
549
+ // ENOENT (missing target): an Edit fails on its own (nothing to
550
+ // re-anchor), so pass through. A Write, though, CREATES the file with its
551
+ // content verbatim — and a Write candidate is always hinted (isCandidate
552
+ // requires the hint prefix), so its placeholder stands for a secret that
553
+ // does NOT exist on this new path. R4: persisting "[REDACTED…]" literally
554
+ // there is the same cross-file/stale-placeholder mistake a same-file Write
555
+ // is denied for; refuse with the same guidance rather than write the
556
+ // placeholder text as a real value.
557
+ if (nodeErr?.code === "ENOENT") {
558
+ if (tool !== "Write") return null;
559
+ return {
560
+ deny:
561
+ `${toolInput.file_path} does not exist, so the ${hint}…] placeholder in the ` +
562
+ `new content stands for no secret on disk; a new file cannot copy a placeholder ` +
563
+ `from another file or context. Write the secret's real value directly, or ask ` +
564
+ `the user to make this change`,
565
+ };
566
+ }
567
+ // Any OTHER read failure (EACCES, EMFILE, a transient I/O error, …) means
568
+ // the target very likely still EXISTS with real bytes on disk — the read
569
+ // failed, the file didn't vanish. A hinted call's content may carry
570
+ // placeholder text that must never be persisted literally over whatever
571
+ // secret is actually there, so fail closed with a deny instead of the
572
+ // silent pass-through above. A non-hinted call was never going to write a
573
+ // secret-shaped placeholder either way, and the underlying tool call will
574
+ // hit this exact same read error itself, so let it propagate rather than
575
+ // swallow an unexpected failure.
576
+ if (!hinted) throw err;
577
+ return {
578
+ deny:
579
+ `could not read ${toolInput.file_path} to rehydrate its secrets ` +
580
+ `(${nodeErr?.code ?? nodeErr?.message}); the file likely still exists, so writing ` +
581
+ `the placeholder text as-is risks overwriting a real secret. Retry the read, or ` +
582
+ `ask the user to make this change directly`,
583
+ };
584
+ }
585
+ const { layer1Cleaned, cleaned } = layer1View(content);
586
+ // A Layer-1-clean file's view differs from disk ONLY at redacted secrets.
587
+ // R1: if nothing is redacted, a hint-free old_string cannot touch a hidden
588
+ // span, so keep the fast pass-through (a verbatim match needs no translation;
589
+ // a mismatch is an ordinary stale old_string Edit reports itself) and never
590
+ // invoke the redactor's map mode. But if the file DOES hold secrets, a
591
+ // hint-free old_string can still match disk bytes INSIDE a redacted span the
592
+ // model never saw — the char-by-char extraction oracle. Fall through to the
593
+ // resolver so its overlap/exposure guards run before any such byte is spliced
594
+ // raw. `io.redact` (plain mode) is the cheap secrets-present probe; it returns
595
+ // null exactly when the file has no secrets.
596
+ if (!hinted && cleaned === content && (await io.redact(cleaned)) === null)
597
+ return null;
598
+
599
+ // alignDeletions needs a true subsequence of `content`; the lone-surrogate
600
+ // normalization folded into `cleaned` is a substitution, not a deletion
601
+ // (see layer1View), so deletions are computed against the pre-normalization
602
+ // text. The substitution is same-length, so the resulting offsets remain
603
+ // valid against `cleaned` throughout the rest of this module.
604
+ const deletions = alignDeletions(content, layer1Cleaned);
605
+ const view = await io.redactMap(cleaned);
606
+ if ("unmappable" in view) {
607
+ if (!hinted) return null;
608
+ return {
609
+ deny: `cannot resolve redaction placeholders in ${toolInput.file_path}: ${view.unmappable}`,
610
+ };
611
+ }
612
+ // The redactor emits code-point offsets; the offset machinery below works in
613
+ // UTF-16. Normalize once here so an astral char before a placeholder can't
614
+ // mis-anchor the edit (identical to a no-op for BMP-only files).
615
+ view.pairs = pairsToUtf16(view.text, view.pairs);
616
+ // View identical to disk: any placeholders in an Edit's old_string are
617
+ // literal text, so there is nothing to re-anchor. `cleaned === content` also
618
+ // rules out a lone-surrogate-only divergence (view.pairs/deletions alone
619
+ // would miss that, since the normalization is neither a redaction pair nor a
620
+ // Layer-1 deletion). A Write is the exception: its content still carries the
621
+ // hint prefix (isCandidate guaranteed it), and with no own placeholder to
622
+ // resolve that hint is a FOREIGN [REDACTED…] placeholder that would be
623
+ // persisted verbatim over pristine bytes. Fall through to rehydrateWrite so
624
+ // it denies with the cross-file guidance — the same verdict a Write onto a
625
+ // secret-bearing or absent target already gets.
626
+ if (
627
+ view.pairs.length === 0 &&
628
+ deletions.length === 0 &&
629
+ cleaned === content &&
630
+ !(tool === "Write" && toolInput.content.includes(hint))
631
+ )
632
+ return null;
633
+
634
+ return tool === "Edit"
635
+ ? rehydrateEdit(
636
+ toolInput,
637
+ content,
638
+ cleaned,
639
+ view,
640
+ deletions,
641
+ io,
642
+ hinted,
643
+ hint,
644
+ )
645
+ : rehydrateWrite(toolInput, view, io, hint);
646
+ }