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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
|
@@ -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
|
+
}
|