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.
@@ -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 { randomBytes } from "node:crypto";
39
- import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
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 `realChild` is `realRoot` itself or lives beneath it. Both inputs
273
- * must already be realpath-resolved absolute paths. Containment is tested with
274
- * `relative(root, child)`: the result is "" when they are the same path, and
275
- * for a true descendant it is a forward path with no `..` segment and is not
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(realRoot, realChild) {
283
- const rel = relative(realRoot, realChild);
284
- return (
285
- rel === "" ||
286
- (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
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 carries an
351
- * absolute `parentPath`, which normalizes to exactly one root-relative,
352
- * `/`-separated entry per walked directory whatever shape the pattern has.
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
- return globSync(globs, {
368
- cwd,
369
- withFileTypes: true,
370
- exclude: (dirent) => {
371
- const entry = relative(root, absolute(dirent)).split(sep).join("/");
372
- return excludeNodeModules(entry) || (exclude?.(entry) ?? false);
373
- },
374
- }).map(absolute);
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
- * Throws if the file cannot be read or written (the caller decides whether an
558
- * unwritable contaminated file is fatal or falls back to alerting).
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 {(path: string) => import("node:fs").Stats} [lstat] injectable
561
- * pre-rename recheck stat (fault-injection seam, mirrors
562
- * {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent
563
- * write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise
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(absPath, lstat = lstatSync) {
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(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
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(absPath);
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(absPath, stripped, before.mode);
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
- let intrusion = null;
199
- for (const matchStart of occurrences(content, oldS)) {
198
+ const intrudes = occurrences(content, oldS).some((matchStart) => {
200
199
  const matchEnd = matchStart + oldS.length;
201
- const secret = diskSpans.find(
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
- if (secret) {
208
- intrusion = { matchStart, matchEnd, secret };
209
- break;
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 bytes ${intrusion.matchStart}-${intrusion.matchEnd}, which fall ` +
220
- `inside a ${hint}…] redacted secret at bytes ${intrusion.secret.start}-` +
221
- `${intrusion.secret.end} that are hidden from your view; edit only text you ` +
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
- span.invisibleBytes > 0 &&
358
- `the matched region carries ${span.invisibleBytes} invisible/control character(s) stripped from your view; they are replaced along with it`,
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` counts UTF-16 code units, matching the "character(s)" prose
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: number} | {deny: string}}
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) return { text: "", pairs: [], restoredChars: 0 };
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: 0 };
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.length - span.cleanedText.length,
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.length - cleaned.length,
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 + suffix.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 {number} restoredChars UTF-16 code units of stripped characters restored with the unchanged regions
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, restoredChars, secretCount, hint) {
730
+ function writeContext(filePath, restored, secretCount, hint) {
687
731
  const parts = [];
688
- if (restoredChars > 0)
732
+ if (restored.n > 0)
689
733
  parts.push(
690
- `${restoredChars} invisible/control character(s) stripped from your view of ` +
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. `invisibleBytes` counts stripped characters
350
- * inside the span (replaced along with it); runs at the boundaries stay
351
- * outside and are preserved. `cleanedText` is the span's Layer-1 view — the
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. This module imports nothing.
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`. Lexical, on already-absolute paths, and the
18
- * bound on where an instruction-file scanner may REWRITE: a file above the scan
19
- * root is shared with every other project beneath that root, so it is reported
20
- * rather than silently edited. Both scanners ask this — one for a target it
21
- * globbed, one for a path an event handed it — and a copy each is a copy that
22
- * can drift into rewriting a file the other would not.
23
- *
24
- * Symlinks are deliberately not resolved: the guard that stops a link from
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[];