agent-sanitizer 2.61.3 → 2.62.1
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 +3 -1
- package/THREAT-MODEL.md +5 -1
- package/bin/sanitize-cli.mjs +11 -9
- package/claude-hooks/lib/invisible-report.mjs +4 -2
- package/claude-hooks/lib/redactor-client.mjs +13 -4
- package/claude-hooks/scan-invisible-chars.mjs +14 -9
- package/claude-hooks/scan-loaded-instructions.mjs +6 -3
- package/package.json +1 -1
- package/src/atomic-replace.mjs +74 -0
- package/src/claude-context.mjs +45 -13
- package/src/glob-compat.mjs +84 -0
- package/src/instructions.mjs +90 -118
- package/src/rehydrate.mjs +74 -30
- package/src/view-map.mjs +3 -4
- package/src/warnings.mjs +5 -2
- package/types/atomic-replace.d.mts +14 -0
- package/types/claude-context.d.mts +18 -10
- package/types/claude-hooks/lib/invisible-report.d.mts +0 -10
- package/types/glob-compat.d.mts +28 -0
- package/types/instructions.d.mts +18 -38
- package/types/rehydrate.d.mts +8 -0
- package/types/src/atomic-replace.d.mts +14 -0
- package/types/src/claude-context.d.mts +18 -10
- package/types/src/glob-compat.d.mts +28 -0
- package/types/src/instructions.d.mts +18 -38
- package/types/view-map.d.mts +3 -4
- package/types/warnings.d.mts +0 -18
package/src/instructions.mjs
CHANGED
|
@@ -22,21 +22,16 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import {
|
|
24
24
|
readFileSync,
|
|
25
|
-
writeFileSync,
|
|
26
25
|
globSync,
|
|
27
|
-
renameSync,
|
|
28
26
|
lstatSync,
|
|
29
27
|
fstatSync,
|
|
30
28
|
realpathSync,
|
|
31
29
|
openSync,
|
|
32
|
-
fsyncSync,
|
|
33
|
-
fchmodSync,
|
|
34
30
|
closeSync,
|
|
35
|
-
unlinkSync,
|
|
36
31
|
constants,
|
|
37
32
|
} from "node:fs";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
33
|
+
import { basename, join, relative, resolve, dirname, sep } from "node:path";
|
|
34
|
+
import { atomicReplaceFile } from "./atomic-replace.mjs";
|
|
40
35
|
import {
|
|
41
36
|
findLongRuns,
|
|
42
37
|
SCATTERED_THRESHOLD,
|
|
@@ -44,7 +39,8 @@ import {
|
|
|
44
39
|
payloadVerdict,
|
|
45
40
|
stripInvisible,
|
|
46
41
|
} from "./invisible.mjs";
|
|
47
|
-
import { excludeNodeModules } from "./claude-context.mjs";
|
|
42
|
+
import { excludeNodeModules, isInsideDir } from "./claude-context.mjs";
|
|
43
|
+
import { compatGlobs } from "./glob-compat.mjs";
|
|
48
44
|
|
|
49
45
|
// The library's public door onto Claude Code's context scope. The definitions
|
|
50
46
|
// live in a dependency-free data module because the SessionStart hook imports
|
|
@@ -269,21 +265,38 @@ export function scanText(content) {
|
|
|
269
265
|
}
|
|
270
266
|
|
|
271
267
|
/**
|
|
272
|
-
* True when `
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* itself absolute — so a sibling like `/proj-evil` (relative => `../proj-evil`)
|
|
277
|
-
* is correctly rejected.
|
|
278
|
-
* @param {string} realRoot
|
|
279
|
-
* @param {string} realChild
|
|
268
|
+
* True when `child` is `root` itself or beneath it: {@link isInsideDir} widened
|
|
269
|
+
* by the root (a glob can match the scan root; a write never targets it).
|
|
270
|
+
* @param {string} root
|
|
271
|
+
* @param {string} child
|
|
280
272
|
* @returns {boolean}
|
|
281
273
|
*/
|
|
282
|
-
function isContained(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
274
|
+
function isContained(root, child) {
|
|
275
|
+
return relative(root, child) === "" || isInsideDir(root, child);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The path a write to `absPath` may use, proven inside `root`: the realpath of
|
|
280
|
+
* its DIRECTORY joined with its own name, checked against the realpath of
|
|
281
|
+
* `root`. Resolving the directory catches a directory symlink in the tree
|
|
282
|
+
* (`.claude/rules -> ~/.claude/rules`); the final component stays unresolved so
|
|
283
|
+
* cleanFile's `O_NOFOLLOW` open still refuses a file symlink. Every later step
|
|
284
|
+
* of the write uses the returned path. Throws when it would land outside `root`.
|
|
285
|
+
* @param {string} absPath
|
|
286
|
+
* @param {string} root
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
function containedWritePath(absPath, root) {
|
|
290
|
+
const realRoot = realpathSync(root);
|
|
291
|
+
const target = join(
|
|
292
|
+
realpathSync(dirname(resolve(absPath))),
|
|
293
|
+
basename(absPath),
|
|
294
|
+
);
|
|
295
|
+
if (isInsideDir(realRoot, target)) return target;
|
|
296
|
+
throw new Error(
|
|
297
|
+
`refusing to clean outside the root ${JSON.stringify(
|
|
298
|
+
realRoot,
|
|
299
|
+
)}: ${JSON.stringify(absPath)} resolves to ${JSON.stringify(target)}`,
|
|
287
300
|
);
|
|
288
301
|
}
|
|
289
302
|
|
|
@@ -347,9 +360,10 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
|
347
360
|
* calls `exclude` once with an entry's bare name and again with its
|
|
348
361
|
* root-relative path, so a predicate holding `build` cannot tell a top-level
|
|
349
362
|
* `build/` from a tracked `src/build/` and prunes both; under an absolute
|
|
350
|
-
* pattern it is handed absolute paths and matches neither. A Dirent
|
|
351
|
-
*
|
|
352
|
-
*
|
|
363
|
+
* pattern it is handed absolute paths and matches neither. A Dirent's
|
|
364
|
+
* `parentPath`, resolved against `cwd` (not the process's: a literal prefix's
|
|
365
|
+
* directories arrive RELATIVE to `cwd`, and a bare join would prune `.claude/`),
|
|
366
|
+
* normalizes to one root-relative, `/`-separated entry per walked directory.
|
|
353
367
|
* @param {string[]} globs
|
|
354
368
|
* @param {string} cwd
|
|
355
369
|
* @param {(entry: string) => boolean} [exclude]
|
|
@@ -364,14 +378,35 @@ export function walkContextGlobs(globs, cwd, exclude) {
|
|
|
364
378
|
// under a `.claude/worktrees/` checkout.
|
|
365
379
|
/** @param {import("node:fs").Dirent} dirent @returns {string} */
|
|
366
380
|
const absolute = (dirent) => resolve(root, dirent.parentPath, dirent.name);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
381
|
+
/** @param {string} entry @returns {boolean} */
|
|
382
|
+
const excluded = (entry) =>
|
|
383
|
+
excludeNodeModules(entry) || (exclude?.(entry) ?? false);
|
|
384
|
+
return (
|
|
385
|
+
globSync(compatGlobs(globs), {
|
|
386
|
+
cwd,
|
|
387
|
+
withFileTypes: true,
|
|
388
|
+
exclude: (dirent) =>
|
|
389
|
+
excluded(relative(root, absolute(dirent)).split(sep).join("/")),
|
|
390
|
+
})
|
|
391
|
+
.map(absolute)
|
|
392
|
+
// Node never consults `exclude` on some literal segments (the `.claude`
|
|
393
|
+
// compatGlobs spells out on an affected Node is one), so a match under a
|
|
394
|
+
// directory the caller pruned is dropped here instead.
|
|
395
|
+
.filter((path) => !ancestorEntries(relative(root, path)).some(excluded))
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Every directory above `rel` (a root-relative path), as `/`-separated
|
|
401
|
+
* entries outermost first: `a/b/c.md` gives `a` and `a/b`.
|
|
402
|
+
* @param {string} rel
|
|
403
|
+
* @returns {string[]}
|
|
404
|
+
*/
|
|
405
|
+
function ancestorEntries(rel) {
|
|
406
|
+
const segments = rel.split(sep);
|
|
407
|
+
return segments
|
|
408
|
+
.slice(0, -1)
|
|
409
|
+
.map((_, i) => segments.slice(0, i + 1).join("/"));
|
|
375
410
|
}
|
|
376
411
|
|
|
377
412
|
/**
|
|
@@ -439,81 +474,6 @@ export function scanInstructionFiles(
|
|
|
439
474
|
return out;
|
|
440
475
|
}
|
|
441
476
|
|
|
442
|
-
/**
|
|
443
|
-
* Atomically replace `absPath`'s contents with `data`, preserving `mode`.
|
|
444
|
-
*
|
|
445
|
-
* Writes to a sibling temp in the same directory, then `rename`s it over the
|
|
446
|
-
* original (same dir => same filesystem => the rename is atomic, not a
|
|
447
|
-
* cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to
|
|
448
|
-
* crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the
|
|
449
|
-
* path already exists — including an attacker-planted symlink at a guessable
|
|
450
|
-
* temp name — the open fails (EEXIST) and does NOT follow the link to clobber
|
|
451
|
-
* its target. On the rare collision we fail loud rather than retry into a
|
|
452
|
-
* different attacker-controlled path.
|
|
453
|
-
*
|
|
454
|
-
* Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the
|
|
455
|
-
* rename and the directory fd is `fsync`ed after it, so a power loss can't leave
|
|
456
|
-
* the renamed name pointing at unflushed/empty data or lose the rename itself.
|
|
457
|
-
* The EXACT `mode` is applied with `fchmod` (openSync's create mode is
|
|
458
|
-
* umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s
|
|
459
|
-
* the temp before rethrowing so no partial temp leaks. `tmpName` and `remove`
|
|
460
|
-
* are injectable fault-injection seams for tests (force a known temp path; drive
|
|
461
|
-
* a cleanup-unlink failure); production callers never pass them.
|
|
462
|
-
* @param {string} absPath
|
|
463
|
-
* @param {string} data
|
|
464
|
-
* @param {number} mode
|
|
465
|
-
* @param {() => string} [tmpName]
|
|
466
|
-
* @param {(path: string) => void} [remove]
|
|
467
|
-
*/
|
|
468
|
-
export function atomicReplaceFile(
|
|
469
|
-
absPath,
|
|
470
|
-
data,
|
|
471
|
-
mode,
|
|
472
|
-
tmpName = () => `.${randomBytes(12).toString("hex")}.tmp`,
|
|
473
|
-
remove = unlinkSync,
|
|
474
|
-
) {
|
|
475
|
-
const dir = dirname(absPath);
|
|
476
|
-
const tmp = join(dir, tmpName());
|
|
477
|
-
// Exclusive create: EEXIST (incl. a planted symlink at the temp name)
|
|
478
|
-
// propagates directly — no temp of ours exists yet, so nothing to clean up
|
|
479
|
-
// and we must never unlink the attacker's pre-existing path.
|
|
480
|
-
const fd = openSync(
|
|
481
|
-
tmp,
|
|
482
|
-
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
483
|
-
mode,
|
|
484
|
-
);
|
|
485
|
-
try {
|
|
486
|
-
// writeFileSync(fd, …) loops until every byte is written (a bare writeSync
|
|
487
|
-
// can short-write a large payload); it does not close the caller's fd.
|
|
488
|
-
writeFileSync(fd, data);
|
|
489
|
-
// Preserve the EXACT mode: openSync's create mode is umask-masked, fchmod is
|
|
490
|
-
// not, so this restores bits (e.g. group-write) the umask would have dropped.
|
|
491
|
-
fchmodSync(fd, mode);
|
|
492
|
-
// Flush the temp's bytes before the rename, else a crash can expose the
|
|
493
|
-
// renamed name pointing at empty/partial content.
|
|
494
|
-
fsyncSync(fd);
|
|
495
|
-
} catch (err) {
|
|
496
|
-
closeSync(fd);
|
|
497
|
-
try {
|
|
498
|
-
remove(tmp);
|
|
499
|
-
} catch {
|
|
500
|
-
// Best-effort cleanup only: rethrow the ORIGINAL failure below, not a
|
|
501
|
-
// secondary unlink error, so the real cause stays loud.
|
|
502
|
-
}
|
|
503
|
-
throw err;
|
|
504
|
-
}
|
|
505
|
-
closeSync(fd);
|
|
506
|
-
renameSync(tmp, absPath);
|
|
507
|
-
// fsync the DIRECTORY so the rename (a directory metadata change) is durable
|
|
508
|
-
// across a crash, not just the file's data blocks.
|
|
509
|
-
const dirFd = openSync(dir, constants.O_RDONLY);
|
|
510
|
-
try {
|
|
511
|
-
fsyncSync(dirFd);
|
|
512
|
-
} finally {
|
|
513
|
-
closeSync(dirFd);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
477
|
/**
|
|
518
478
|
* Strip payload-capable invisible characters from `absPath` in place. Returns
|
|
519
479
|
* `true` when the file's bytes actually changed (a payload {@link scanText}
|
|
@@ -554,20 +514,32 @@ export function atomicReplaceFile(
|
|
|
554
514
|
* a temp file in the same directory which is then `rename`d over the original
|
|
555
515
|
* (preserving the original file mode), fsync'd for crash-safety.
|
|
556
516
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
517
|
+
* The rewrite must land strictly inside `root`, judged on realpaths (see
|
|
518
|
+
* {@link containedWritePath}): `O_NOFOLLOW` guards only the final component, so
|
|
519
|
+
* a directory symlink would otherwise carry it outside. `root` is required so
|
|
520
|
+
* no caller writes unbounded.
|
|
521
|
+
*
|
|
522
|
+
* Throws if the file cannot be read or written, or lies outside `root`.
|
|
559
523
|
* @param {string} absPath
|
|
560
|
-
* @param {
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* unreachable from this fully-synchronous path. Defaults to `lstatSync`.
|
|
524
|
+
* @param {{ root: string,
|
|
525
|
+
* lstat?: (path: string) => import("node:fs").Stats }} options
|
|
526
|
+
* `root` bounds the write. `lstat` is the injectable pre-rename recheck stat
|
|
527
|
+
* (fault-injection seam) that lets a test drive the TOCTOU guard.
|
|
565
528
|
* @returns {boolean}
|
|
566
529
|
*/
|
|
567
|
-
export function cleanFile(
|
|
530
|
+
export function cleanFile(
|
|
531
|
+
absPath,
|
|
532
|
+
{ root, lstat = lstatSync } = /** @type {any} */ ({}),
|
|
533
|
+
) {
|
|
534
|
+
// The `{}` default routes a bare `cleanFile(path)` to the TypeError below.
|
|
535
|
+
if (typeof root !== "string")
|
|
536
|
+
throw new TypeError(
|
|
537
|
+
"cleanFile requires options.root, the directory the rewrite must stay inside",
|
|
538
|
+
);
|
|
539
|
+
const path = containedWritePath(absPath, root);
|
|
568
540
|
let fd;
|
|
569
541
|
try {
|
|
570
|
-
fd = openSync(
|
|
542
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
571
543
|
} catch (err) {
|
|
572
544
|
// O_NOFOLLOW on a symlink fails ELOOP (some libc report EMLINK); surface
|
|
573
545
|
// the same "regular files only" contract the old lstat check did. Other
|
|
@@ -609,7 +581,7 @@ export function cleanFile(absPath, lstat = lstatSync) {
|
|
|
609
581
|
// Re-verify the on-path file against the open-time snapshot before writing:
|
|
610
582
|
// an inode/size/mtime change (or a swap to a symlink) means someone modified
|
|
611
583
|
// it under us, so fail loud rather than clobber their write (lost update).
|
|
612
|
-
const after = lstat(
|
|
584
|
+
const after = lstat(path);
|
|
613
585
|
if (
|
|
614
586
|
after.isSymbolicLink() ||
|
|
615
587
|
after.ino !== before.ino ||
|
|
@@ -623,7 +595,7 @@ export function cleanFile(absPath, lstat = lstatSync) {
|
|
|
623
595
|
);
|
|
624
596
|
// `before.mode` is the opened regular file's mode. A crash before the rename
|
|
625
597
|
// leaves the original intact; after it the new content is fully present.
|
|
626
|
-
atomicReplaceFile(
|
|
598
|
+
atomicReplaceFile(path, stripped, before.mode);
|
|
627
599
|
return true;
|
|
628
600
|
} finally {
|
|
629
601
|
closeSync(fd);
|
package/src/rehydrate.mjs
CHANGED
|
@@ -195,31 +195,24 @@ async function rehydrateEdit(
|
|
|
195
195
|
// (the model supplied the secret's real bytes itself, e.g. a rotation)
|
|
196
196
|
// extracts nothing and is left to the literal resolver below.
|
|
197
197
|
const diskSpans = pairDiskSpans(view, deletions);
|
|
198
|
-
|
|
199
|
-
for (const matchStart of occurrences(content, oldS)) {
|
|
198
|
+
const intrudes = occurrences(content, oldS).some((matchStart) => {
|
|
200
199
|
const matchEnd = matchStart + oldS.length;
|
|
201
|
-
|
|
200
|
+
return diskSpans.some(
|
|
202
201
|
(span) =>
|
|
203
202
|
matchStart < span.end &&
|
|
204
203
|
span.start < matchEnd &&
|
|
205
204
|
!(matchStart <= span.start && span.end <= matchEnd),
|
|
206
205
|
);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
// Report both byte ranges. The refusal is conservative — it fires on an
|
|
213
|
-
// overlap with the redacted span the caller cannot see — so the caller
|
|
214
|
-
// needs the ranges to tell a true overlap from a mis-sized redaction.
|
|
215
|
-
if (intrusion)
|
|
206
|
+
});
|
|
207
|
+
// No offsets: a span is the secret's length, and the match's position
|
|
208
|
+
// against it says how much of the secret the probe covered.
|
|
209
|
+
if (intrudes)
|
|
216
210
|
return {
|
|
217
211
|
deny:
|
|
218
212
|
`old_string does not appear in the sanitized view of ${ti.file_path}; on disk ` +
|
|
219
|
-
`it matches
|
|
220
|
-
`
|
|
221
|
-
|
|
222
|
-
`can see (include each placeholder whole), or ask the user to make this change`,
|
|
213
|
+
`it matches text inside a ${hint}…] redacted secret that is hidden from your ` +
|
|
214
|
+
`view; edit only text you can see (include each placeholder whole), or ask ` +
|
|
215
|
+
`the user to make this change`,
|
|
223
216
|
};
|
|
224
217
|
const literalRes = rehydrateNewString(
|
|
225
218
|
oldS,
|
|
@@ -351,11 +344,12 @@ async function rehydrateEdit(
|
|
|
351
344
|
if (exposed > 0) return { deny: exposureDeny(exposed) };
|
|
352
345
|
}
|
|
353
346
|
|
|
347
|
+
const stripped = strippedChars(span.diskText, span.cleanedText);
|
|
354
348
|
const notes = [
|
|
355
349
|
span.pairs.length > 0 &&
|
|
356
350
|
`${hint}…] placeholders were resolved to the file's real secret values (still hidden from you)`,
|
|
357
|
-
|
|
358
|
-
`the matched region carries ${
|
|
351
|
+
stripped.n > 0 &&
|
|
352
|
+
`the matched region carries ${strippedPhrase(stripped)} stripped from your view; they are replaced along with it`,
|
|
359
353
|
].filter(Boolean);
|
|
360
354
|
return {
|
|
361
355
|
updatedInput: { ...ti, old_string: span.diskText, new_string: newRes.text },
|
|
@@ -438,17 +432,17 @@ function foreignPlaceholders(out, hint, viewText, secretSpans) {
|
|
|
438
432
|
* region reaches that edge — re-attach those explicitly, since `diskOffset`
|
|
439
433
|
* keeps boundary runs outside the span in both directions.
|
|
440
434
|
*
|
|
441
|
-
* `restoredChars`
|
|
442
|
-
* in {@link writeContext}.
|
|
435
|
+
* `restoredChars` is a code-point {@link SpacedCount} ({@link strippedChars}).
|
|
443
436
|
* @param {WriteRestoreContext} ctx per-Write invariants shared by both regions
|
|
444
437
|
* @param {number} viewStart
|
|
445
438
|
* @param {number} viewEnd
|
|
446
439
|
* @param {string} fallback the model's own bytes for this region
|
|
447
|
-
* @returns {{text: string, pairs: readonly {placeholder: string, original: string, start: number}[], restoredChars:
|
|
440
|
+
* @returns {{text: string, pairs: readonly {placeholder: string, original: string, start: number}[], restoredChars: SpacedCount<"codePoint">} | {deny: string}}
|
|
448
441
|
*/
|
|
449
442
|
function restoreWriteRegion(ctx, viewStart, viewEnd, fallback) {
|
|
450
443
|
const { content, cleaned, view, deletions } = ctx;
|
|
451
|
-
if (viewStart >= viewEnd)
|
|
444
|
+
if (viewStart >= viewEnd)
|
|
445
|
+
return { text: "", pairs: [], restoredChars: NO_STRIPS };
|
|
452
446
|
const span = resolveSpan(
|
|
453
447
|
content,
|
|
454
448
|
cleaned,
|
|
@@ -478,15 +472,54 @@ function restoreWriteRegion(ctx, viewStart, viewEnd, fallback) {
|
|
|
478
472
|
ctx.filePath,
|
|
479
473
|
"use Edit for the changed region",
|
|
480
474
|
);
|
|
481
|
-
return { text: fallback, pairs: [], restoredChars:
|
|
475
|
+
return { text: fallback, pairs: [], restoredChars: NO_STRIPS };
|
|
482
476
|
}
|
|
483
477
|
return {
|
|
484
478
|
text: diskText,
|
|
485
479
|
pairs: span.pairs,
|
|
486
|
-
restoredChars: diskText
|
|
480
|
+
restoredChars: strippedChars(diskText, span.cleanedText),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A count tagged with the {@link import("./view-map.mjs").OffsetSpace} it was
|
|
486
|
+
* measured in, so the prose that renders it takes its unit from the value.
|
|
487
|
+
* @template {import("./view-map.mjs").OffsetSpace} S
|
|
488
|
+
* @typedef {{ readonly n: number, readonly space: S }} SpacedCount
|
|
489
|
+
*/
|
|
490
|
+
|
|
491
|
+
/** @type {SpacedCount<"codePoint">} */
|
|
492
|
+
const NO_STRIPS = Object.freeze({ n: 0, space: "codePoint" });
|
|
493
|
+
|
|
494
|
+
// The noun each count space renders as; only code points have one, so a count
|
|
495
|
+
// in another space fails the type check at {@link strippedPhrase}.
|
|
496
|
+
const STRIPPED_NOUN = Object.freeze({
|
|
497
|
+
codePoint: "invisible/control character(s)",
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* How many characters Layer 1 stripped from `diskText` to leave `cleanedText`,
|
|
502
|
+
* in code points (a UTF-16 difference double-counts every astral strip).
|
|
503
|
+
* @param {string} diskText on-disk bytes of a region
|
|
504
|
+
* @param {string} cleanedText that region's Layer-1 view
|
|
505
|
+
* @returns {SpacedCount<"codePoint">}
|
|
506
|
+
*/
|
|
507
|
+
function strippedChars(diskText, cleanedText) {
|
|
508
|
+
return {
|
|
509
|
+
n: [...diskText].length - [...cleanedText].length,
|
|
510
|
+
space: "codePoint",
|
|
487
511
|
};
|
|
488
512
|
}
|
|
489
513
|
|
|
514
|
+
/**
|
|
515
|
+
* `count` as the prose names it, the unit read from its space.
|
|
516
|
+
* @param {SpacedCount<"codePoint">} count
|
|
517
|
+
* @returns {string}
|
|
518
|
+
*/
|
|
519
|
+
function strippedPhrase(count) {
|
|
520
|
+
return `${count.n} ${STRIPPED_NOUN[count.space]}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
490
523
|
/**
|
|
491
524
|
* The per-Write invariants `restoreWriteRegion` needs for both regions,
|
|
492
525
|
* bundled once in {@link rehydrateWrite} instead of threaded as positional
|
|
@@ -537,7 +570,7 @@ async function rehydrateWrite(ti, content, cleaned, view, deletions, io, hint) {
|
|
|
537
570
|
updatedInput: { ...ti, content },
|
|
538
571
|
context: writeContext(
|
|
539
572
|
ti.file_path,
|
|
540
|
-
content
|
|
573
|
+
strippedChars(content, cleaned),
|
|
541
574
|
view.pairs.length,
|
|
542
575
|
hint,
|
|
543
576
|
),
|
|
@@ -669,25 +702,36 @@ async function rehydrateWrite(ti, content, cleaned, view, deletions, io, hint) {
|
|
|
669
702
|
updatedInput: { ...ti, content: out },
|
|
670
703
|
context: writeContext(
|
|
671
704
|
ti.file_path,
|
|
672
|
-
prefix.restoredChars
|
|
705
|
+
addCounts(prefix.restoredChars, suffix.restoredChars),
|
|
673
706
|
middleSpans.length + prefix.pairs.length + suffix.pairs.length,
|
|
674
707
|
hint,
|
|
675
708
|
),
|
|
676
709
|
};
|
|
677
710
|
}
|
|
678
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Sum of two counts in one space; two spaces do not type-check.
|
|
714
|
+
* @template {import("./view-map.mjs").OffsetSpace} S
|
|
715
|
+
* @param {SpacedCount<S>} a
|
|
716
|
+
* @param {SpacedCount<S>} b
|
|
717
|
+
* @returns {SpacedCount<S>}
|
|
718
|
+
*/
|
|
719
|
+
function addCounts(a, b) {
|
|
720
|
+
return { n: a.n + b.n, space: a.space };
|
|
721
|
+
}
|
|
722
|
+
|
|
679
723
|
/**
|
|
680
724
|
* Model-facing context line for a rewritten Write input.
|
|
681
725
|
* @param {string} filePath
|
|
682
|
-
* @param {
|
|
726
|
+
* @param {SpacedCount<"codePoint">} restored stripped characters restored with the unchanged regions
|
|
683
727
|
* @param {number} secretCount placeholders resolved (spliced or restored)
|
|
684
728
|
* @param {string} hint placeholder prefix
|
|
685
729
|
*/
|
|
686
|
-
function writeContext(filePath,
|
|
730
|
+
function writeContext(filePath, restored, secretCount, hint) {
|
|
687
731
|
const parts = [];
|
|
688
|
-
if (
|
|
732
|
+
if (restored.n > 0)
|
|
689
733
|
parts.push(
|
|
690
|
-
`${
|
|
734
|
+
`${strippedPhrase(restored)} stripped from your view of ` +
|
|
691
735
|
`${filePath} were restored from disk in the regions your Write left unchanged.`,
|
|
692
736
|
);
|
|
693
737
|
if (secretCount > 0)
|
package/src/view-map.mjs
CHANGED
|
@@ -346,9 +346,9 @@ function mapViewOffset(pairs, offset) {
|
|
|
346
346
|
* Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction
|
|
347
347
|
* pairs it wholly contains, mapping across placeholder expansion (view →
|
|
348
348
|
* cleaned) and stripped invisible runs (cleaned → disk). Null when a boundary
|
|
349
|
-
* cuts through a placeholder.
|
|
350
|
-
*
|
|
351
|
-
*
|
|
349
|
+
* cuts through a placeholder. Stripped runs inside the span are replaced along
|
|
350
|
+
* with it; runs at the boundaries stay outside and are preserved. `cleanedText`
|
|
351
|
+
* is the span's Layer-1 view — the
|
|
352
352
|
* caller MUST verify that re-cleaning `diskText` reproduces it before acting:
|
|
353
353
|
* greedy alignment is ambiguous when a deleted run's edge character equals the
|
|
354
354
|
* adjacent kept character (an ANSI sequence ending in `m` before a kept `m`),
|
|
@@ -379,7 +379,6 @@ export function resolveSpan(
|
|
|
379
379
|
return {
|
|
380
380
|
diskText,
|
|
381
381
|
cleanedText: cleaned.slice(cleanedStart, cleanedEnd),
|
|
382
|
-
invisibleBytes: diskText.length - (cleanedEnd - cleanedStart),
|
|
383
382
|
pairs: view.pairs.filter(
|
|
384
383
|
(pair) =>
|
|
385
384
|
pair.start >= viewStart &&
|
package/src/warnings.mjs
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Every function returns COUNTS and reasons, never the removed content itself:
|
|
16
16
|
* echoing what Layer 2 just spliced out would re-inject the payload into the
|
|
17
|
-
* very context the splice removed it from.
|
|
17
|
+
* very context the splice removed it from. The target `describeExfil` names is
|
|
18
|
+
* not removed content but is still scanned-input text, so it goes through
|
|
19
|
+
* {@link displayUntrusted}.
|
|
18
20
|
*/
|
|
21
|
+
import { displayUntrusted } from "./claude-context.mjs";
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Layer 1's lone-surrogate warning. A bare constant rather than a literal at
|
|
@@ -93,7 +96,7 @@ export function describeExfil(threats) {
|
|
|
93
96
|
...new Set(
|
|
94
97
|
threats.map(
|
|
95
98
|
(threat) =>
|
|
96
|
-
`${threat.isImage ? "image" : "link"} to ${threat.target}: ${threat.reason}`,
|
|
99
|
+
`${threat.isImage ? "image" : "link"} to ${displayUntrusted(threat.target)}: ${threat.reason}`,
|
|
97
100
|
),
|
|
98
101
|
),
|
|
99
102
|
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomically replace `absPath`'s contents with `data`, preserving `mode`: write
|
|
3
|
+
* an exclusively created (O_EXCL, unpredictable-name) sibling temp, fsync it,
|
|
4
|
+
* `rename` it over the original, fsync the directory. O_EXCL means a planted
|
|
5
|
+
* symlink at the temp name fails EEXIST instead of being followed. A failed
|
|
6
|
+
* write unlinks the temp before rethrowing. `tmpName` and `remove` are
|
|
7
|
+
* fault-injection seams for tests.
|
|
8
|
+
* @param {string} absPath
|
|
9
|
+
* @param {string} data
|
|
10
|
+
* @param {number} mode
|
|
11
|
+
* @param {() => string} [tmpName]
|
|
12
|
+
* @param {(path: string) => void} [remove]
|
|
13
|
+
*/
|
|
14
|
+
export function atomicReplaceFile(absPath: string, data: string, mode: number, tmpName?: () => string, remove?: (path: string) => void): void;
|
|
@@ -14,21 +14,29 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export function ancestorInstructionFiles(dir: string): string[];
|
|
16
16
|
/**
|
|
17
|
-
* Whether `file` lives inside `dir
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* redirecting the write has to live at the write itself (cleanFile opens
|
|
26
|
-
* O_NOFOLLOW), and resolving here would only duplicate it a check too early.
|
|
17
|
+
* Whether `file` lives strictly inside `dir` — the ONE containment predicate:
|
|
18
|
+
* both scanners gate their auto-clean on it, the glob walk keeps a match by it,
|
|
19
|
+
* and cleanFile refuses a write it rejects. A file above the scan root is
|
|
20
|
+
* shared with other projects, so it is reported rather than edited.
|
|
21
|
+
*
|
|
22
|
+
* Lexical; cleanFile hands it realpaths, which stops a directory symlink from
|
|
23
|
+
* redirecting a rewrite. `..` is tested as a whole SEGMENT, so `..notes.md` is
|
|
24
|
+
* inside and `../x` is not.
|
|
27
25
|
* @param {string} dir
|
|
28
26
|
* @param {string} file
|
|
29
27
|
* @returns {boolean}
|
|
30
28
|
*/
|
|
31
29
|
export function isInsideDir(dir: string, file: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Untrusted text made safe to render into a report, stderr or model context:
|
|
32
|
+
* every {@link DISPLAY_UNSAFE} code point becomes a visible `\u{XXXX}` escape,
|
|
33
|
+
* everything else is kept. Scanners pass every path from a possibly hostile
|
|
34
|
+
* tree and every reason that may quote one through it. Display only — the
|
|
35
|
+
* result never opens a file.
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function displayUntrusted(text: string): string;
|
|
32
40
|
/**
|
|
33
41
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
34
42
|
* function so the name is spelled once, and so the two predicates that need it
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The operator-facing report for hidden-Unicode findings in instruction files.
|
|
3
|
-
*
|
|
4
|
-
* Its own module because two hooks render it — the SessionStart scan of the
|
|
5
|
-
* files that load at launch, and the InstructionsLoaded scan of every file
|
|
6
|
-
* loaded after that — and a second copy would drift in exactly the way that
|
|
7
|
-
* matters: the framing that keeps a decoded payload from reading as an
|
|
8
|
-
* instruction (see decodeRun's `untrusted data` prefix) is part of the report,
|
|
9
|
-
* not decoration on it.
|
|
10
|
-
*/
|
|
11
1
|
/**
|
|
12
2
|
* @param {Array<{
|
|
13
3
|
* file: string,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the globSync of Node `version` (`process.versions.node`'s shape)
|
|
3
|
+
* misses a dot-named segment after a `**`.
|
|
4
|
+
* @param {string} version
|
|
5
|
+
* @returns {boolean}
|
|
6
|
+
*/
|
|
7
|
+
export function globstarSkipsDotSegments(version: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* `pattern` as patterns the broken globSync answers.
|
|
10
|
+
*
|
|
11
|
+
* Each `**\/` before a dot segment spans zero directories or one or more:
|
|
12
|
+
* dropping it is the first case and `**\/*\/` the second, and neither puts a
|
|
13
|
+
* dot segment straight after a `**`. A `*` never matches a dot-named directory,
|
|
14
|
+
* so the halves can match less than a fixed Node does (which enters a dot
|
|
15
|
+
* directory named like the segment after `**`), never more. A dot segment the
|
|
16
|
+
* rewrite cannot reach throws rather than silently matching nothing.
|
|
17
|
+
* @param {string} pattern
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
export function expandDotGlobstar(pattern: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* `patterns` ready for this runtime's globSync: rewritten by
|
|
23
|
+
* {@link expandDotGlobstar} on an affected Node, untouched otherwise.
|
|
24
|
+
* @param {string[]} patterns
|
|
25
|
+
* @param {string} [version] defaults to the running Node
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function compatGlobs(patterns: string[], version?: string): string[];
|