agent-sanitizer 2.29.1 → 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.
- package/README.md +21 -21
- package/THREAT-MODEL.md +59 -19
- package/claude-hooks/lib/placeholder-grammar.mjs +162 -84
- package/claude-hooks/lib/reveal.mjs +129 -5
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +159 -2
- package/claude-hooks/sanitize-output.mjs +90 -21
- package/package.json +1 -1
- package/src/gates.mjs +5 -3
- package/src/html.mjs +123 -26
- package/src/index.mjs +32 -18
- package/src/output.mjs +84 -19
- package/src/rehydrate.mjs +333 -101
- package/src/view-map.mjs +70 -0
- package/src/warnings.mjs +14 -0
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +75 -29
- package/types/claude-hooks/lib/reveal.d.mts +46 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +35 -0
- package/types/claude-hooks/sanitize-output.d.mts +18 -3
- package/types/gates.d.mts +5 -3
- package/types/html.d.mts +74 -20
- package/types/index.d.mts +19 -10
- package/types/output.d.mts +24 -3
- package/types/rehydrate.d.mts +16 -0
- package/types/view-map.d.mts +31 -0
- package/types/warnings.d.mts +12 -0
package/src/rehydrate.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* characters, and secret redaction replaces secrets with [REDACTED…]
|
|
8
8
|
* placeholders. An Edit whose old_string was copied from that view then fails
|
|
9
9
|
* exact-match against the real file, and a whole-file Write would persist
|
|
10
|
-
* placeholder text over the real secret
|
|
11
|
-
*
|
|
10
|
+
* placeholder text over the real secret — or silently drop the stripped
|
|
11
|
+
* characters from every region it faithfully echoed back. This module closes
|
|
12
|
+
* the loop without ever showing the model a secret: it re-derives the sanitized view of the
|
|
12
13
|
* target file (the shared {@link applyLayer1}, then the injected redactor's
|
|
13
14
|
* map mode), locates the model's old_string in that view, and maps it
|
|
14
15
|
* span-exact back to the on-disk bytes — across both placeholder expansion and
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
makeFileView,
|
|
66
67
|
toUtf16View,
|
|
67
68
|
pairDiskSpans,
|
|
69
|
+
anchorSpans,
|
|
68
70
|
viewMapDefect,
|
|
69
71
|
} from "./view-map.mjs";
|
|
70
72
|
|
|
@@ -307,12 +309,11 @@ async function rehydrateEdit(
|
|
|
307
309
|
layer1View(span.diskText).cleaned !== span.cleanedText ||
|
|
308
310
|
(span.diskText !== oldS && content.includes(oldS));
|
|
309
311
|
if (anchorAmbiguous)
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
};
|
|
312
|
+
return anchorAmbiguityDeny(
|
|
313
|
+
"the matched region",
|
|
314
|
+
ti.file_path,
|
|
315
|
+
"edit a smaller region away from them",
|
|
316
|
+
);
|
|
316
317
|
const newRes = rehydrateNewString(
|
|
317
318
|
oldS,
|
|
318
319
|
ti.new_string,
|
|
@@ -362,6 +363,25 @@ async function rehydrateEdit(
|
|
|
362
363
|
};
|
|
363
364
|
}
|
|
364
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The shared anchor-ambiguity refusal: a stripped run abuts kept text it
|
|
368
|
+
* resembles, so greedy deletion alignment cannot prove which bytes the region
|
|
369
|
+
* owns. Edit's searched spans and Write's position-anchored regions hit the
|
|
370
|
+
* same soundness gate and must speak the same language — one builder so the
|
|
371
|
+
* two deny sentences cannot drift apart.
|
|
372
|
+
* @param {string} lead what could not be anchored ("the matched region", …)
|
|
373
|
+
* @param {string} filePath
|
|
374
|
+
* @param {string} guidance the caller-specific way out, without trailing punctuation
|
|
375
|
+
*/
|
|
376
|
+
function anchorAmbiguityDeny(lead, filePath, guidance) {
|
|
377
|
+
return {
|
|
378
|
+
deny:
|
|
379
|
+
`${lead} sits next to stripped control sequences that cannot be ` +
|
|
380
|
+
`re-anchored unambiguously in ${filePath}; ${guidance}, or ask the user ` +
|
|
381
|
+
`to make this change`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
365
385
|
/**
|
|
366
386
|
* Foreign redaction placeholders surviving in post-substitution content `out`:
|
|
367
387
|
* hint-prefixed, placeholder-shaped tokens that are neither introduced by a
|
|
@@ -399,39 +419,171 @@ function foreignPlaceholders(out, hint, viewText, secretSpans) {
|
|
|
399
419
|
}
|
|
400
420
|
|
|
401
421
|
/**
|
|
422
|
+
* Restore one position-anchored view region of a Write — the common prefix or
|
|
423
|
+
* suffix `anchorSpans` proved unchanged — to its on-disk bytes. Unlike Edit's
|
|
424
|
+
* searched spans, the region is position-fixed, so the only residual hazard is
|
|
425
|
+
* greedy-alignment run misattribution, caught by the same re-clean soundness
|
|
426
|
+
* gate Edit uses (Edit's clause (b), the verbatim-collision check, cannot
|
|
427
|
+
* apply to a span that was never searched for). On gate failure the outcome
|
|
428
|
+
* depends on what the region holds: a placeholder-free region falls back to
|
|
429
|
+
* the model's own bytes (fail open — the write merely loses stripped
|
|
430
|
+
* characters, exactly today's behavior), while a placeholder-bearing region is
|
|
431
|
+
* denied (restoring at a misattributed anchor could graft secret bytes
|
|
432
|
+
* wrongly; not restoring persists placeholder text over the secret — neither
|
|
433
|
+
* open option is safe).
|
|
434
|
+
*
|
|
435
|
+
* `resolveSpan`'s boundary semantics deliberately drop a stripped run sitting
|
|
436
|
+
* exactly at the region's interior edge (it may belong to the changed middle),
|
|
437
|
+
* but a run at the very start or end OF THE FILE is unambiguous when the
|
|
438
|
+
* region reaches that edge — re-attach those explicitly, since `diskOffset`
|
|
439
|
+
* keeps boundary runs outside the span in both directions.
|
|
440
|
+
*
|
|
441
|
+
* `restoredChars` counts UTF-16 code units, matching the "character(s)" prose
|
|
442
|
+
* in {@link writeContext}.
|
|
443
|
+
* @param {WriteRestoreContext} ctx per-Write invariants shared by both regions
|
|
444
|
+
* @param {number} viewStart
|
|
445
|
+
* @param {number} viewEnd
|
|
446
|
+
* @param {string} fallback the model's own bytes for this region
|
|
447
|
+
* @returns {{text: string, pairs: readonly {placeholder: string, original: string, start: number}[], restoredChars: number} | {deny: string}}
|
|
448
|
+
*/
|
|
449
|
+
function restoreWriteRegion(ctx, viewStart, viewEnd, fallback) {
|
|
450
|
+
const { content, cleaned, view, deletions } = ctx;
|
|
451
|
+
if (viewStart >= viewEnd) return { text: "", pairs: [], restoredChars: 0 };
|
|
452
|
+
const span = resolveSpan(
|
|
453
|
+
content,
|
|
454
|
+
cleaned,
|
|
455
|
+
view,
|
|
456
|
+
deletions,
|
|
457
|
+
viewStart,
|
|
458
|
+
viewEnd,
|
|
459
|
+
);
|
|
460
|
+
/* c8 ignore start -- anchorSpans snapped both boundaries out of placeholder
|
|
461
|
+
interiors, the only way resolveSpan returns null; kept as a fail-loud
|
|
462
|
+
guard against a future regression in that snapping. */
|
|
463
|
+
if (span === null)
|
|
464
|
+
throw new Error("write anchor cut a placeholder despite boundary snapping");
|
|
465
|
+
/* c8 ignore stop */
|
|
466
|
+
const first = deletions[0];
|
|
467
|
+
const atStart = viewStart === 0 && first?.start === 0 ? first.deleted : "";
|
|
468
|
+
const last = deletions[deletions.length - 1];
|
|
469
|
+
const atEnd =
|
|
470
|
+
viewEnd === view.text.length && last?.start === cleaned.length
|
|
471
|
+
? last.deleted
|
|
472
|
+
: "";
|
|
473
|
+
const diskText = atStart + span.diskText + atEnd;
|
|
474
|
+
if (layer1View(diskText).cleaned !== span.cleanedText) {
|
|
475
|
+
if (span.pairs.length > 0)
|
|
476
|
+
return anchorAmbiguityDeny(
|
|
477
|
+
`the unchanged region around a ${ctx.hint}…] placeholder`,
|
|
478
|
+
ctx.filePath,
|
|
479
|
+
"use Edit for the changed region",
|
|
480
|
+
);
|
|
481
|
+
return { text: fallback, pairs: [], restoredChars: 0 };
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
text: diskText,
|
|
485
|
+
pairs: span.pairs,
|
|
486
|
+
restoredChars: diskText.length - span.cleanedText.length,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The per-Write invariants `restoreWriteRegion` needs for both regions,
|
|
492
|
+
* bundled once in {@link rehydrateWrite} instead of threaded as positional
|
|
493
|
+
* parameters.
|
|
494
|
+
* @typedef {{
|
|
495
|
+
* filePath: string,
|
|
496
|
+
* content: string,
|
|
497
|
+
* cleaned: string,
|
|
498
|
+
* view: import("./view-map.mjs").FileView<"utf16">,
|
|
499
|
+
* deletions: {start: number, deleted: string}[],
|
|
500
|
+
* hint: string,
|
|
501
|
+
* }} WriteRestoreContext
|
|
502
|
+
*/
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Re-anchor a whole-file Write against the target's sanitized view. The
|
|
506
|
+
* regions `anchorSpans` proves unchanged (common prefix/suffix, in view space)
|
|
507
|
+
* are restored to their on-disk bytes — redacted secrets AND Layer-1-stripped
|
|
508
|
+
* runs come back position-exact — while the genuinely-changed middle keeps the
|
|
509
|
+
* model's bytes, with this file's placeholders substituted for their secrets
|
|
510
|
+
* (Layer-1 strips of NEW text stay stripped: that is the sanitizer working).
|
|
402
511
|
* @param {{file_path: string, content: string}} ti
|
|
512
|
+
* @param {string} content disk bytes
|
|
513
|
+
* @param {string} cleaned Layer-1 view of `content`
|
|
403
514
|
* @param {import("./view-map.mjs").FileView<"utf16">} view
|
|
515
|
+
* @param {{start: number, deleted: string}[]} deletions
|
|
404
516
|
* @param {RehydrateIo} io
|
|
405
517
|
* @param {string} hint placeholder prefix
|
|
406
518
|
*/
|
|
407
|
-
async function rehydrateWrite(ti, view, io, hint) {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
519
|
+
async function rehydrateWrite(ti, content, cleaned, view, deletions, io, hint) {
|
|
520
|
+
const viewText = view.text;
|
|
521
|
+
// An EMPTY view means the model can see nothing of the file — an
|
|
522
|
+
// all-invisible file, the archetypal hidden-payload artifact. A Write there
|
|
523
|
+
// (of "" or of anything else) is the model replacing content it was told is
|
|
524
|
+
// suspicious, not echoing content back; restoring the hidden bytes would
|
|
525
|
+
// actively defeat that cleanup. Keep the model's bytes (fail open).
|
|
526
|
+
if (ti.content === viewText && viewText !== "") {
|
|
527
|
+
// A hinted Write of a PRISTINE file's view (the hint is literal prose the
|
|
528
|
+
// file already had) — nothing diverges, nothing to do.
|
|
529
|
+
if (content === ti.content) return null;
|
|
530
|
+
// Faithful whole-file round-trip: the incoming content IS the sanitized
|
|
531
|
+
// view, so the write becomes the disk bytes themselves — placeholders
|
|
532
|
+
// resolve to their secrets and every stripped run comes back. Provably
|
|
533
|
+
// sound with no gate: the view was derived from exactly these bytes, so
|
|
534
|
+
// re-sanitizing reproduces it, nothing new can be exposed, and no foreign
|
|
535
|
+
// placeholder can appear.
|
|
419
536
|
return {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
537
|
+
updatedInput: { ...ti, content },
|
|
538
|
+
context: writeContext(
|
|
539
|
+
ti.file_path,
|
|
540
|
+
content.length - cleaned.length,
|
|
541
|
+
view.pairs.length,
|
|
542
|
+
hint,
|
|
543
|
+
),
|
|
425
544
|
};
|
|
545
|
+
}
|
|
546
|
+
const { prefixEnd, suffixStart } = anchorSpans(ti.content, view);
|
|
547
|
+
const suffixLen = viewText.length - suffixStart;
|
|
548
|
+
const ctx = {
|
|
549
|
+
filePath: ti.file_path,
|
|
550
|
+
content,
|
|
551
|
+
cleaned,
|
|
552
|
+
view,
|
|
553
|
+
deletions,
|
|
554
|
+
hint,
|
|
555
|
+
};
|
|
556
|
+
const prefix = restoreWriteRegion(
|
|
557
|
+
ctx,
|
|
558
|
+
0,
|
|
559
|
+
prefixEnd,
|
|
560
|
+
ti.content.slice(0, prefixEnd),
|
|
561
|
+
);
|
|
562
|
+
if ("deny" in prefix) return prefix;
|
|
563
|
+
const suffix = restoreWriteRegion(
|
|
564
|
+
ctx,
|
|
565
|
+
suffixStart,
|
|
566
|
+
viewText.length,
|
|
567
|
+
suffixLen === 0 ? "" : ti.content.slice(-suffixLen),
|
|
568
|
+
);
|
|
569
|
+
if ("deny" in suffix) return suffix;
|
|
426
570
|
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
571
|
+
// The changed middle, in content space (prefix/suffix are common substrings,
|
|
572
|
+
// so their view-space lengths index ti.content directly). Only this file's
|
|
573
|
+
// OWN placeholder texts occurring here are substituted; placeholders wholly
|
|
574
|
+
// inside the restored prefix/suffix need no substitution — resolveSpan
|
|
575
|
+
// already brought back the real disk secrets byte-exact.
|
|
576
|
+
const middle = ti.content.slice(prefixEnd, ti.content.length - suffixLen);
|
|
577
|
+
const texts = [...new Set(view.pairs.map((pair) => pair.placeholder))].filter(
|
|
578
|
+
(phText) => middle.includes(phText),
|
|
579
|
+
);
|
|
580
|
+
// Resolve each placeholder text to its single secret first, then splice in
|
|
581
|
+
// ONE ordered pass (R6) via the shared `spliceOrdered` — see its doc for why
|
|
582
|
+
// a chained `out.split(ph).join(secret)` per placeholder is unsound.
|
|
431
583
|
const valueByPh = new Map();
|
|
432
584
|
for (const phText of texts) {
|
|
433
585
|
const produced = view.pairs.filter((pair) => pair.placeholder === phText);
|
|
434
|
-
if (occurrences(
|
|
586
|
+
if (occurrences(viewText, phText).length > produced.length)
|
|
435
587
|
return {
|
|
436
588
|
deny:
|
|
437
589
|
`${ti.file_path} mixes literal "${phText}" text with a redacted secret sharing ` +
|
|
@@ -448,46 +600,112 @@ async function rehydrateWrite(ti, view, io, hint) {
|
|
|
448
600
|
};
|
|
449
601
|
valueByPh.set(phText, values[0]);
|
|
450
602
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// excluded from the foreign-placeholder scan below.
|
|
455
|
-
const { text: out, spans: secretSpans } = spliceOrdered(
|
|
456
|
-
ti.content,
|
|
457
|
-
orderedMatches(ti.content, texts),
|
|
603
|
+
const { text: middleOut, spans: middleSpans } = spliceOrdered(
|
|
604
|
+
middle,
|
|
605
|
+
orderedMatches(middle, texts),
|
|
458
606
|
(match) => valueByPh.get(match.text),
|
|
459
607
|
);
|
|
460
|
-
const
|
|
608
|
+
const out = prefix.text + middleOut + suffix.text;
|
|
461
609
|
|
|
462
|
-
// R3: the
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
610
|
+
// R3: the content may still carry a FOREIGN placeholder — one pasted from
|
|
611
|
+
// another file/context that shares the hint prefix but is not one of this
|
|
612
|
+
// file's own. It would be persisted verbatim over a real secret. Compare the
|
|
613
|
+
// ACTUAL placeholder STRINGS, not scalar hint counts: a scalar comparison is
|
|
614
|
+
// defeated by an edit that drops one literal hint and adds one foreign
|
|
615
|
+
// placeholder (the counts net to zero). `secretSpans` are the byte ranges in
|
|
616
|
+
// `out` occupied by secret VALUES (substituted in the middle, or restored
|
|
617
|
+
// with the prefix/suffix) — a hint occurrence inside one is a pathological
|
|
618
|
+
// secret whose bytes contain the hint prefix, NOT a pasted placeholder, so
|
|
619
|
+
// it is excluded from the scan.
|
|
620
|
+
if (out.includes(hint)) {
|
|
621
|
+
const diskSpans = pairDiskSpans(view, deletions);
|
|
622
|
+
const secretSpans = [];
|
|
623
|
+
// A restored prefix starts at disk offset 0, so disk offsets ARE out
|
|
624
|
+
// offsets there; a restored suffix is the file's tail, shifted by however
|
|
625
|
+
// much the content ahead of it grew or shrank.
|
|
626
|
+
for (const pair of prefix.pairs)
|
|
627
|
+
secretSpans.push(diskSpans[view.pairs.indexOf(pair)]);
|
|
628
|
+
const suffixShift = out.length - content.length;
|
|
629
|
+
for (const pair of suffix.pairs) {
|
|
630
|
+
const diskSpan = diskSpans[view.pairs.indexOf(pair)];
|
|
631
|
+
secretSpans.push({
|
|
632
|
+
start: diskSpan.start + suffixShift,
|
|
633
|
+
end: diskSpan.end + suffixShift,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
for (const span of middleSpans)
|
|
637
|
+
secretSpans.push({
|
|
638
|
+
start: span.start + prefix.text.length,
|
|
639
|
+
end: span.end + prefix.text.length,
|
|
640
|
+
});
|
|
641
|
+
if (foreignPlaceholders(out, hint, viewText, secretSpans).length > 0)
|
|
642
|
+
return {
|
|
643
|
+
deny:
|
|
644
|
+
`the new content still carries a ${hint}…] placeholder that does not match any ` +
|
|
645
|
+
`secret in ${ti.file_path}, so a whole-file Write cannot copy a placeholder from ` +
|
|
646
|
+
`another file or context; request the source file's content and rehydrate a ` +
|
|
647
|
+
`same-file Edit instead, or write the secret's real value directly`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
478
650
|
|
|
479
|
-
|
|
480
|
-
|
|
651
|
+
// Nothing restored, nothing substituted: the write proceeds with the
|
|
652
|
+
// model's own bytes exactly as it would have without this layer.
|
|
653
|
+
if (out === ti.content) return null;
|
|
654
|
+
|
|
655
|
+
const secrets = [
|
|
656
|
+
...valueByPh.values(),
|
|
657
|
+
...prefix.pairs.map((pair) => pair.original),
|
|
658
|
+
...suffix.pairs.map((pair) => pair.original),
|
|
659
|
+
];
|
|
660
|
+
// Writing back the file's exact disk bytes cannot expose anything: the next
|
|
661
|
+
// sanitized view is byte-identical to the prior one. Skip the redactor
|
|
662
|
+
// round-trip on that (common) faithful-round-trip case.
|
|
663
|
+
if (out !== content) {
|
|
664
|
+
const exposed = await exposedSecrets(secrets, viewText, out, io);
|
|
665
|
+
if (exposed > 0) return { deny: exposureDeny(exposed) };
|
|
666
|
+
}
|
|
481
667
|
|
|
482
668
|
return {
|
|
483
669
|
updatedInput: { ...ti, content: out },
|
|
484
|
-
context:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
670
|
+
context: writeContext(
|
|
671
|
+
ti.file_path,
|
|
672
|
+
prefix.restoredChars + suffix.restoredChars,
|
|
673
|
+
middleSpans.length + prefix.pairs.length + suffix.pairs.length,
|
|
674
|
+
hint,
|
|
675
|
+
),
|
|
488
676
|
};
|
|
489
677
|
}
|
|
490
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Model-facing context line for a rewritten Write input.
|
|
681
|
+
* @param {string} filePath
|
|
682
|
+
* @param {number} restoredChars UTF-16 code units of stripped characters restored with the unchanged regions
|
|
683
|
+
* @param {number} secretCount placeholders resolved (spliced or restored)
|
|
684
|
+
* @param {string} hint placeholder prefix
|
|
685
|
+
*/
|
|
686
|
+
function writeContext(filePath, restoredChars, secretCount, hint) {
|
|
687
|
+
const parts = [];
|
|
688
|
+
if (restoredChars > 0)
|
|
689
|
+
parts.push(
|
|
690
|
+
`${restoredChars} invisible/control character(s) stripped from your view of ` +
|
|
691
|
+
`${filePath} were restored from disk in the regions your Write left unchanged.`,
|
|
692
|
+
);
|
|
693
|
+
if (secretCount > 0)
|
|
694
|
+
parts.push(
|
|
695
|
+
`Write content contained ${hint}…] placeholders; they were resolved to the ` +
|
|
696
|
+
`file's real secret values on disk (still hidden from you), so the secrets ` +
|
|
697
|
+
`are preserved in the written file.`,
|
|
698
|
+
);
|
|
699
|
+
// Reachable with both counts zero: a lone surrogate the view normalized to
|
|
700
|
+
// U+FFFD restores same-length, changing bytes but neither counter.
|
|
701
|
+
if (parts.length === 0)
|
|
702
|
+
parts.push(
|
|
703
|
+
`unchanged regions of your Write were restored to the exact on-disk bytes of ` +
|
|
704
|
+
`${filePath} (differing only by characters hidden from your view).`,
|
|
705
|
+
);
|
|
706
|
+
return parts.join(" ");
|
|
707
|
+
}
|
|
708
|
+
|
|
491
709
|
/**
|
|
492
710
|
* The single MultiEdit refusal: covers a sanitized view that diverges
|
|
493
711
|
* from disk (redacted secrets, stripped invisible characters, a lone
|
|
@@ -511,20 +729,21 @@ function multiEditDeny(filePath) {
|
|
|
511
729
|
|
|
512
730
|
/**
|
|
513
731
|
* True when this tool call could need re-anchoring against the target file's
|
|
514
|
-
* sanitized view: any well-formed Edit (the view may differ from
|
|
515
|
-
* without placeholders, via stripped invisible characters
|
|
516
|
-
*
|
|
517
|
-
*
|
|
732
|
+
* sanitized view: any well-formed Edit or Write (the view may differ from
|
|
733
|
+
* disk even without placeholders, via stripped invisible characters — a
|
|
734
|
+
* hint-free whole-file Write of such a file would silently persist the
|
|
735
|
+
* stripped bytes), and any well-formed MultiEdit (gated on the same
|
|
736
|
+
* grounds).
|
|
518
737
|
* @param {string} tool
|
|
519
738
|
* @param {any} ti
|
|
520
|
-
* @param {string} hint
|
|
521
739
|
*/
|
|
522
|
-
function isCandidate(tool, ti
|
|
740
|
+
function isCandidate(tool, ti) {
|
|
523
741
|
if (typeof ti?.file_path !== "string") return false;
|
|
524
742
|
if (tool === "Edit")
|
|
525
743
|
return (
|
|
526
744
|
typeof ti.old_string === "string" && typeof ti.new_string === "string"
|
|
527
745
|
);
|
|
746
|
+
if (tool === "Write") return typeof ti.content === "string";
|
|
528
747
|
// MultiEdit applies its edits SEQUENTIALLY, each against the result of the
|
|
529
748
|
// previous, so the span machinery below (which maps one old_string against
|
|
530
749
|
// one static view) cannot re-anchor it. It is still a candidate: on a
|
|
@@ -543,8 +762,6 @@ function isCandidate(tool, ti, hint) {
|
|
|
543
762
|
typeof edit?.new_string === "string",
|
|
544
763
|
)
|
|
545
764
|
);
|
|
546
|
-
if (tool === "Write")
|
|
547
|
-
return typeof ti.content === "string" && ti.content.includes(hint);
|
|
548
765
|
return false;
|
|
549
766
|
}
|
|
550
767
|
|
|
@@ -586,16 +803,17 @@ export async function rehydrateRedacted(
|
|
|
586
803
|
`hidden from your view; rehydration is not supported for notebooks. Keep ` +
|
|
587
804
|
`the secret-bearing cell unchanged, or ask the user to edit it.`,
|
|
588
805
|
};
|
|
589
|
-
if (!isCandidate(tool, toolInput
|
|
806
|
+
if (!isCandidate(tool, toolInput)) return null;
|
|
590
807
|
const hinted =
|
|
591
|
-
tool === "Write"
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
toolInput.
|
|
808
|
+
tool === "Write"
|
|
809
|
+
? toolInput.content.includes(hint)
|
|
810
|
+
: tool === "MultiEdit"
|
|
811
|
+
? toolInput.edits.some(
|
|
812
|
+
(/** @type {{old_string: string, new_string: string}} */ edit) =>
|
|
813
|
+
edit.old_string.includes(hint) || edit.new_string.includes(hint),
|
|
814
|
+
)
|
|
815
|
+
: toolInput.old_string.includes(hint) ||
|
|
816
|
+
toolInput.new_string.includes(hint);
|
|
599
817
|
|
|
600
818
|
let content;
|
|
601
819
|
try {
|
|
@@ -609,14 +827,15 @@ export async function rehydrateRedacted(
|
|
|
609
827
|
// its own (nothing to re-anchor), so pass through — a hint-free call, an
|
|
610
828
|
// Edit whose old_string is non-empty, or a MultiEdit whose FIRST edit's
|
|
611
829
|
// 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).
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
830
|
+
// form; anything else errors not-found in the real tool). A hint-free
|
|
831
|
+
// Write is file CREATION too — there is no prior view and nothing to
|
|
832
|
+
// restore, so it passes through on the `!hinted` arm. But any call that
|
|
833
|
+
// WOULD create the file with hinted content — a hinted Write, a hinted
|
|
834
|
+
// Edit-create, or a hinted MultiEdit-create — persists its placeholder
|
|
835
|
+
// verbatim, standing for a secret that does NOT exist on this new path.
|
|
836
|
+
// R4: that is the same cross-file/stale-placeholder mistake a same-file
|
|
837
|
+
// Write is denied for; refuse with the same guidance rather than write
|
|
838
|
+
// the placeholder text as a real value.
|
|
620
839
|
if (nodeErr?.code === "ENOENT") {
|
|
621
840
|
const creates =
|
|
622
841
|
tool === "Write" ||
|
|
@@ -637,11 +856,19 @@ export async function rehydrateRedacted(
|
|
|
637
856
|
// failed, the file didn't vanish. A hinted call's content may carry
|
|
638
857
|
// placeholder text that must never be persisted literally over whatever
|
|
639
858
|
// secret is actually there, so fail closed with a deny instead of the
|
|
640
|
-
// silent pass-through above. A non-hinted
|
|
641
|
-
// secret-shaped placeholder
|
|
642
|
-
// hit this exact same
|
|
643
|
-
// swallow an unexpected failure.
|
|
644
|
-
|
|
859
|
+
// silent pass-through above. A non-hinted Edit/MultiEdit was never going
|
|
860
|
+
// to write a secret-shaped placeholder, and the underlying tool call
|
|
861
|
+
// reads the file itself, so it will hit this exact same error — let it
|
|
862
|
+
// propagate rather than swallow an unexpected failure. A non-hinted
|
|
863
|
+
// WRITE never reads its target: pre-restoration it would simply proceed,
|
|
864
|
+
// so blocking it on a read error this layer alone performed would fail
|
|
865
|
+
// closed on a placeholder-free ambiguity. Pass it through (fail open —
|
|
866
|
+
// restoration is best-effort; the write merely loses stripped
|
|
867
|
+
// characters, exactly the pre-restoration behavior).
|
|
868
|
+
if (!hinted) {
|
|
869
|
+
if (tool !== "Write") throw err;
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
645
872
|
return {
|
|
646
873
|
deny:
|
|
647
874
|
`could not read ${toolInput.file_path} to rehydrate its secrets ` +
|
|
@@ -655,9 +882,11 @@ export async function rehydrateRedacted(
|
|
|
655
882
|
// R1: if nothing is redacted, a hint-free old_string cannot touch a hidden
|
|
656
883
|
// span, so keep the fast pass-through (a verbatim match needs no translation;
|
|
657
884
|
// a mismatch is an ordinary stale old_string Edit reports itself) and never
|
|
658
|
-
// invoke the redactor's map mode.
|
|
659
|
-
//
|
|
660
|
-
//
|
|
885
|
+
// invoke the redactor's map mode. The same holds for a hint-free Write:
|
|
886
|
+
// with no stripped run and no secret, the view IS the disk bytes and there
|
|
887
|
+
// is nothing to restore. But if the file DOES hold secrets, a hint-free
|
|
888
|
+
// old_string can still match disk bytes INSIDE a redacted span the model
|
|
889
|
+
// never saw — the char-by-char extraction oracle. Fall through to the
|
|
661
890
|
// resolver so its overlap/exposure guards run before any such byte is spliced
|
|
662
891
|
// raw. `io.redact` (plain mode) is the cheap secrets-present probe; it returns
|
|
663
892
|
// null exactly when the file has no secrets.
|
|
@@ -733,16 +962,19 @@ export async function rehydrateRedacted(
|
|
|
733
962
|
};
|
|
734
963
|
}
|
|
735
964
|
// View identical to disk: any placeholders in an Edit's old_string are
|
|
736
|
-
// literal text, so there is nothing to re-anchor
|
|
737
|
-
//
|
|
738
|
-
//
|
|
965
|
+
// literal text, so there is nothing to re-anchor — and a hint-free Write of
|
|
966
|
+
// a pristine file has nothing to restore. `cleaned === content` also rules
|
|
967
|
+
// out a lone-surrogate-only divergence (view.pairs/deletions alone would
|
|
968
|
+
// miss that, since the normalization is neither a redaction pair nor a
|
|
739
969
|
// Layer-1 deletion). HINTED Write and MultiEdit are the exceptions: their
|
|
740
970
|
// content still carries the hint prefix, and with no own placeholder to
|
|
741
|
-
// resolve that hint
|
|
971
|
+
// resolve that hint may be a FOREIGN [REDACTED…] placeholder that would be
|
|
742
972
|
// persisted verbatim over pristine bytes. A Write falls through to
|
|
743
|
-
// rehydrateWrite's
|
|
744
|
-
//
|
|
745
|
-
// the
|
|
973
|
+
// rehydrateWrite's foreign-placeholder scan (which denies a genuinely
|
|
974
|
+
// foreign token and passes literal prose the file already had); a MultiEdit
|
|
975
|
+
// to the MultiEdit deny below — without this a hinted MultiEdit on a
|
|
976
|
+
// pristine file silently persists the foreign placeholder a byte-identical
|
|
977
|
+
// Write is denied for.
|
|
746
978
|
const viewEqualsDisk =
|
|
747
979
|
view.pairs.length === 0 && deletions.length === 0 && cleaned === content;
|
|
748
980
|
// Precision refinement for a hinted MultiEdit on that PRISTINE file: a
|
|
@@ -782,5 +1014,5 @@ export async function rehydrateRedacted(
|
|
|
782
1014
|
hinted,
|
|
783
1015
|
hint,
|
|
784
1016
|
)
|
|
785
|
-
: rehydrateWrite(toolInput, view, io, hint);
|
|
1017
|
+
: rehydrateWrite(toolInput, content, cleaned, view, deletions, io, hint);
|
|
786
1018
|
}
|
package/src/view-map.mjs
CHANGED
|
@@ -451,6 +451,76 @@ export function spliceOrdered(text, matches, replacementFor) {
|
|
|
451
451
|
return { text: out + text.slice(last), spans };
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Anchor a whole-file Write's content against the sanitized view it was
|
|
456
|
+
* composed from: the longest common prefix and suffix are the regions the
|
|
457
|
+
* model left unchanged, so their on-disk bytes (stripped runs, redacted
|
|
458
|
+
* secrets, lone surrogates included) can be restored position-exact — no
|
|
459
|
+
* search, no anchor ambiguity. Returns view-space `{prefixEnd, suffixStart}`;
|
|
460
|
+
* because the prefix and suffix are common substrings, the same lengths index
|
|
461
|
+
* `content` (prefix `[0, prefixEnd)`, suffix `[content.length - (view.text.length
|
|
462
|
+
* - suffixStart))`).
|
|
463
|
+
*
|
|
464
|
+
* Both boundaries are snapped OUT of hazards, always shrinking the restored
|
|
465
|
+
* region (the fail-open direction — a smaller restore only loses stripped
|
|
466
|
+
* characters, never corrupts):
|
|
467
|
+
* - a boundary strictly inside a placeholder moves to the placeholder's
|
|
468
|
+
* edge, so `resolveSpan` (which returns null on a placeholder-cutting
|
|
469
|
+
* boundary) always resolves;
|
|
470
|
+
* - a boundary splitting a surrogate pair moves off it. The view never
|
|
471
|
+
* carries lone surrogates (they were normalized to U+FFFD), so a high
|
|
472
|
+
* surrogate at `prefixEnd - 1` is always a genuine pair's first half; and
|
|
473
|
+
* since the prefix/suffix are common substrings, checking the view covers
|
|
474
|
+
* the content side too.
|
|
475
|
+
* The prefix is computed first and the suffix capped so they never overlap
|
|
476
|
+
* (prefix wins — deterministic).
|
|
477
|
+
* @param {string} content incoming Write content (view space)
|
|
478
|
+
* @param {FileView<"utf16">} view sanitized view of the target file
|
|
479
|
+
* @returns {{prefixEnd: number, suffixStart: number}}
|
|
480
|
+
*/
|
|
481
|
+
export function anchorSpans(content, view) {
|
|
482
|
+
assertFileView(view, "utf16", "anchorSpans");
|
|
483
|
+
const viewText = view.text;
|
|
484
|
+
const maxPrefix = Math.min(content.length, viewText.length);
|
|
485
|
+
let p = 0;
|
|
486
|
+
while (p < maxPrefix && content[p] === viewText[p]) p++;
|
|
487
|
+
const interior = (/** @type {number} */ offset) =>
|
|
488
|
+
view.pairs.find(
|
|
489
|
+
(pair) =>
|
|
490
|
+
pair.start < offset && offset < pair.start + pair.placeholder.length,
|
|
491
|
+
);
|
|
492
|
+
const cut = interior(p);
|
|
493
|
+
if (cut) p = cut.start;
|
|
494
|
+
if (p > 0 && isHighSurrogate(viewText.charCodeAt(p - 1))) p--;
|
|
495
|
+
|
|
496
|
+
const maxSuffix = maxPrefix - p;
|
|
497
|
+
let s = 0;
|
|
498
|
+
while (
|
|
499
|
+
s < maxSuffix &&
|
|
500
|
+
content[content.length - 1 - s] === viewText[viewText.length - 1 - s]
|
|
501
|
+
)
|
|
502
|
+
s++;
|
|
503
|
+
let suffixStart = viewText.length - s;
|
|
504
|
+
const cutEnd = interior(suffixStart);
|
|
505
|
+
if (cutEnd) suffixStart = cutEnd.start + cutEnd.placeholder.length;
|
|
506
|
+
if (
|
|
507
|
+
suffixStart < viewText.length &&
|
|
508
|
+
isLowSurrogate(viewText.charCodeAt(suffixStart))
|
|
509
|
+
)
|
|
510
|
+
suffixStart++;
|
|
511
|
+
return { prefixEnd: p, suffixStart };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** @param {number} code */
|
|
515
|
+
function isHighSurrogate(code) {
|
|
516
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** @param {number} code */
|
|
520
|
+
function isLowSurrogate(code) {
|
|
521
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
522
|
+
}
|
|
523
|
+
|
|
454
524
|
/**
|
|
455
525
|
* On-disk [start, end) span of every redaction pair, mapped from its view
|
|
456
526
|
* offset through placeholder expansion (view → cleaned) and stripped invisible
|