agent-sanitizer 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Instruction-file scanner + auto-cleaner for hidden-Unicode injection.
3
+ *
4
+ * Agent instruction files (CLAUDE.md / AGENTS.md / SKILL.md / any `.claude`
5
+ * markdown) load directly as model context, bypassing a tool-output sanitizer,
6
+ * so invisible Unicode pasted into them reaches the model raw — invisible in an
7
+ * editor but read as instructions. This module finds runs of payload-capable
8
+ * invisible characters, decodes the common encodings (Unicode-tag → ASCII,
9
+ * zero-width binary), catches scattered threshold-evasion payloads, and (via
10
+ * {@link cleanFile}) strips them.
11
+ *
12
+ * The target file set is CALLER-SUPPLIED: pass the globs your agent's
13
+ * instruction files live under (e.g. `["CLAUDE.md", "AGENTS.md",
14
+ * ".claude/**\/*.md", "**\/SKILL.md"]`), so no agent's convention is baked in.
15
+ */
16
+ import {
17
+ readFileSync,
18
+ writeFileSync,
19
+ globSync,
20
+ renameSync,
21
+ lstatSync,
22
+ fstatSync,
23
+ realpathSync,
24
+ openSync,
25
+ fsyncSync,
26
+ fchmodSync,
27
+ closeSync,
28
+ unlinkSync,
29
+ constants,
30
+ } from "node:fs";
31
+ import { randomBytes } from "node:crypto";
32
+ import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
33
+ import {
34
+ LONG_RUN_RE,
35
+ SCATTERED_THRESHOLD,
36
+ countPayloadInvisible,
37
+ stripInvisible,
38
+ } from "./invisible.mjs";
39
+
40
+ // Prefix on any decoded tag-character payload. The decoded text is
41
+ // attacker-controlled and flows into the scan report, which itself reaches model
42
+ // context — so it must be framed as DATA, never re-presented as a live
43
+ // instruction the model might follow.
44
+ const UNTRUSTED_PREFIX = "untrusted data, not instructions: ";
45
+
46
+ /**
47
+ * Render decoded tag-character bytes as a NEUTRAL, quoted, escaped string so the
48
+ * scan report can never re-inject them. Only U+E0020–U+E007E decode to their
49
+ * printable ASCII (0x20–0x7E); every other tag byte (the C0 controls SOH…US and
50
+ * DEL that U+E0001–U+E001F / U+E007F would otherwise map to raw) is rendered as a
51
+ * visible `\xNN` escape rather than emitted as an actual control byte. Backslash
52
+ * and the surrounding quote are escaped in the SAME pass so an inserted escape
53
+ * can never be re-touched (CLAUDE.md: incomplete-string-escaping).
54
+ * @param {number[]} asciiCodes raw decoded bytes (cp − 0xE0000), each 0x01–0x7F
55
+ * @returns {string}
56
+ */
57
+ function neutralizeTagBytes(asciiCodes) {
58
+ let out = "";
59
+ for (const code of asciiCodes) {
60
+ if (code === 0x5c) out += "\\\\";
61
+ else if (code === 0x22) out += '\\"';
62
+ else if (code >= 0x20 && code <= 0x7e) out += String.fromCharCode(code);
63
+ else out += `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Decode a run of invisible characters to its likely payload. Recognizes the
70
+ * two common smuggling encodings — Unicode tag characters (U+E0001–U+E007F map
71
+ * directly to ASCII) and zero-width binary (ZWSP=0, ZWNJ=1, ZWJ=separator) —
72
+ * and otherwise reports the raw code points. The tag-character payload is
73
+ * rendered as a neutral, quoted/escaped `untrusted data, not instructions: "…"`
74
+ * string (see {@link neutralizeTagBytes}) so the report can never re-inject the
75
+ * hidden instruction, and only U+E0020–U+E007E map to raw printable ASCII.
76
+ * @param {string} run
77
+ * @returns {{ method: string, decoded: string }}
78
+ */
79
+ export function decodeRun(run) {
80
+ const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
81
+
82
+ // Tag characters U+E0001-U+E007F: raw ASCII byte is cp − 0xE0000 (0x01–0x7F).
83
+ const tagBytes = cps
84
+ .filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
85
+ .map((cp) => cp - 0xe0000);
86
+
87
+ // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
88
+ const ZW_BIT = new Map([
89
+ [0x200b, "0"],
90
+ [0x200c, "1"],
91
+ [0x200d, "|"],
92
+ ]);
93
+
94
+ const zwCount = cps.filter((cp) => ZW_BIT.has(cp)).length;
95
+
96
+ // Only take the tag-characters branch when tag chars are the MAJORITY of the
97
+ // run. A run that is overwhelmingly zero-width bits plus ONE stray tag char is
98
+ // a zero-width-binary payload, not a tag payload — labeling it "Unicode tag
99
+ // characters → ASCII" buries the real (binary) payload behind the wrong
100
+ // method. Reporting accuracy only: the strip removes the whole run regardless.
101
+ if (tagBytes.length > 0 && tagBytes.length > cps.length / 2) {
102
+ // A run can carry BOTH tag-ASCII and zero-width chars; the strip removes the
103
+ // whole run regardless, but the operator-facing `decoded` must reflect the
104
+ // zero-width portion too rather than silently dropping it.
105
+ const note = zwCount > 0 ? ` + ${zwCount} zero-width char(s)` : "";
106
+ return {
107
+ method: "Unicode tag characters → ASCII",
108
+ decoded: `${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"${note}`,
109
+ };
110
+ }
111
+
112
+ // Zero-width-binary branch: the whole run is ZW bits, OR ZW bits are the
113
+ // majority (so a run of many bits plus a stray tag/other char is decoded as
114
+ // the binary payload it actually is, not mislabeled). Decode only the ZW code
115
+ // points; a `+ N other char(s)` note keeps any non-ZW portion visible.
116
+ if (zwCount > 0 && zwCount > cps.length / 2) {
117
+ const bits = cps
118
+ .filter((cp) => ZW_BIT.has(cp))
119
+ .map((cp) => ZW_BIT.get(cp))
120
+ .join("");
121
+ const otherCount = cps.length - zwCount;
122
+ const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
123
+ return {
124
+ method: "zero-width binary encoding",
125
+ decoded: `[${zwCount} zero-width chars: ${bits.slice(0, 80)}]${note}`,
126
+ };
127
+ }
128
+
129
+ // Neither class holds a strict majority (e.g. a 50/50 tag + zero-width run),
130
+ // or the run mixes both classes without one dominating. Raw-dumping U+…
131
+ // codepoints here would bury BOTH payloads; instead decode each recognized
132
+ // sub-encoding and concatenate, keeping any unrecognized remainder visible as
133
+ // a `+ N other char(s)` note.
134
+ if (tagBytes.length > 0 || zwCount > 0) {
135
+ const parts = [];
136
+ if (tagBytes.length > 0)
137
+ parts.push(`${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"`);
138
+ if (zwCount > 0) {
139
+ const bits = cps
140
+ .filter((cp) => ZW_BIT.has(cp))
141
+ .map((cp) => ZW_BIT.get(cp))
142
+ .join("");
143
+ parts.push(`[${zwCount} zero-width chars: ${bits.slice(0, 80)}]`);
144
+ }
145
+ const otherCount = cps.length - tagBytes.length - zwCount;
146
+ const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
147
+ return {
148
+ method: "mixed tag + zero-width encodings",
149
+ decoded: parts.join(" ") + note,
150
+ };
151
+ }
152
+
153
+ // Unknown: no recognized sub-encoding present; report the raw code points.
154
+ return {
155
+ method: "invisible Unicode sequence",
156
+ decoded: cps
157
+ .map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
158
+ .join(" "),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Scan a file's text for hidden-Unicode injection. Reports each long invisible
164
+ * run (with its decoded payload) plus a single scattered-chars finding when the
165
+ * non-run invisible count crosses the threshold-evasion floor.
166
+ * @param {string} content
167
+ * @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>}
168
+ * `line` is the 1-based line of a long-run finding, or `null` for the
169
+ * whole-file scattered-chars finding (not tied to a single line).
170
+ */
171
+ export function scanText(content) {
172
+ const findings = [];
173
+ LONG_RUN_RE.lastIndex = 0;
174
+ let match;
175
+ let runChars = 0;
176
+ while ((match = LONG_RUN_RE.exec(content)) !== null) {
177
+ const lineNum = content.slice(0, match.index).split("\n").length;
178
+ const charCount = [...match[0]].length;
179
+ runChars += charCount;
180
+ findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
181
+ }
182
+
183
+ // Threshold-evasion: scattered invisible chars not in a long run can still be
184
+ // a payload. Always evaluated; chars already in a run are excluded so they
185
+ // aren't double-counted. countPayloadInvisible is invisible.mjs's carve-out
186
+ // counter (the SSOT the stripper itself gates on): it discounts every
187
+ // invisible that does real rendering work — emoji presentation selectors
188
+ // (VS16 *and* VS15) on a real pictograph, emoji-sequence ZWJ, and linguistic
189
+ // ZWNJ/ZWJ between cursive letters or after a virama. A hand-rolled emoji-only
190
+ // mirror lived here before and over-counted linguistic joiners and VS15, so a
191
+ // ZWNJ-dense Persian doc or a doc of text-presentation hearts (❤︎) tripped a
192
+ // scattered false positive on content stripInvisible would preserve.
193
+ //
194
+ // Asymmetry (deliberate, benign): the minuend discounts preserved
195
+ // selectors/joiners EVERYWHERE, while `runChars` is each run's RAW length. A
196
+ // long run is ≥LONG_RUN_THRESHOLD *consecutive* invisibles, and a preserved
197
+ // selector/joiner is always flanked by a visible neighbor — so it cannot sit
198
+ // inside such a run, and the two counts describe disjoint chars in practice.
199
+ // In the pathological case that they don't (e.g. a run of stacked VS16), the
200
+ // raw `runChars` subtracts at most a few more than the minuend added, biasing
201
+ // `scattered` slightly LOW — a false negative, the precision-favoring
202
+ // direction, never a spurious finding. `scattered` may even go negative; the
203
+ // `>=` gate treats that as "no scatter", which is correct.
204
+ const scattered = countPayloadInvisible(content) - runChars;
205
+ if (scattered >= SCATTERED_THRESHOLD) {
206
+ findings.push({
207
+ line: null, // whole-file finding: scattered chars aren't tied to one line
208
+ charCount: scattered,
209
+ method: "scattered invisible chars (possible threshold evasion)",
210
+ decoded: `[${scattered} invisible chars distributed across file]`,
211
+ });
212
+ }
213
+
214
+ return findings;
215
+ }
216
+
217
+ /**
218
+ * True when `realChild` is `realRoot` itself or lives beneath it. Both inputs
219
+ * must already be realpath-resolved absolute paths. Containment is tested with
220
+ * `relative(root, child)`: the result is "" when they are the same path, and
221
+ * for a true descendant it is a forward path with no `..` segment and is not
222
+ * itself absolute — so a sibling like `/proj-evil` (relative => `../proj-evil`)
223
+ * is correctly rejected.
224
+ * @param {string} realRoot
225
+ * @param {string} realChild
226
+ * @returns {boolean}
227
+ */
228
+ function isContained(realRoot, realChild) {
229
+ const rel = relative(realRoot, realChild);
230
+ return (
231
+ rel === "" ||
232
+ (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Classify a glob match for containment: resolve it to a real (symlink-
238
+ * followed) path and decide whether to keep, drop, or reject it. Three
239
+ * outcomes are kept distinct:
240
+ *
241
+ * - The realpath is contained in `realRoot` → KEEP.
242
+ * - The match is contained LEXICALLY (the glob pattern itself, followed
243
+ * literally with no symlink resolution, never leaves `literalRoot`) but its
244
+ * REALPATH escapes `realRoot` — an in-tree symlink (e.g. a planted
245
+ * `CLAUDE.md -> /etc/passwd`) whose target lives outside the tree → SKIP.
246
+ * One planted symlink must not abort scanning every other instruction
247
+ * file; treat it like the existing dangling-symlink case.
248
+ * - The match ESCAPES LEXICALLY — the glob pattern itself reaches outside
249
+ * `literalRoot` via `..` or an absolute path outside the tree, with no
250
+ * symlink involved → THROW. That is a caller misconfiguration (a scanner
251
+ * pointed outside its own tree) that must surface loudly, not be silently
252
+ * skipped.
253
+ * - The path cannot be resolved at all (ENOENT/EACCES — a dangling symlink or
254
+ * unreadable entry that still lives inside the tree) → return false to SKIP
255
+ * it, matching scanInstructionFiles' existing skip-on-unreadable behavior.
256
+ *
257
+ * A genuine resolution failure is never allowed to masquerade as a
258
+ * containment pass: an unresolvable path is skipped, only a successfully
259
+ * resolved path is classified as kept/skipped/thrown above.
260
+ * @param {string} absPath absolute path to a glob match
261
+ * @param {string} realRoot realpath of the scan root
262
+ * @param {string} literalRoot `cwd`, resolved but NOT symlink-followed
263
+ * @param {string} pattern the glob that produced this match
264
+ * @returns {boolean} true to keep the match, false to skip it
265
+ */
266
+ function keepContained(absPath, realRoot, literalRoot, pattern) {
267
+ let real;
268
+ try {
269
+ real = realpathSync(absPath);
270
+ } catch {
271
+ return false; // dangling/unreadable in-cwd match: skip, do not abort
272
+ }
273
+ if (isContained(realRoot, real)) return true;
274
+ // The glob pattern itself never left the scan root lexically, so the escape
275
+ // is caused by an in-tree symlink resolving outside the tree, not by the
276
+ // caller's glob configuration. Skip this one match, do not abort the scan.
277
+ if (isContained(literalRoot, absPath)) return false;
278
+ throw new Error(
279
+ `instruction-file path escapes scan root: pattern ${JSON.stringify(
280
+ pattern,
281
+ )} matched ${JSON.stringify(absPath)} which resolves to ${JSON.stringify(
282
+ real,
283
+ )} outside ${JSON.stringify(realRoot)}`,
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Expand `globs` (relative to `cwd`) to absolute file paths, skipping
289
+ * `node_modules`. The glob set is the caller's instruction-file convention.
290
+ *
291
+ * Containment is enforced per match (see {@link keepContained}): a match whose
292
+ * glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
293
+ * outside the tree — THROWS, since reaching outside the tree is a caller
294
+ * misconfiguration. A match that lexically stays inside `cwd` but resolves
295
+ * (via an in-tree symlink) to a target outside the tree, or that simply
296
+ * cannot be resolved (a dangling symlink or unreadable entry inside the
297
+ * tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
298
+ * project.
299
+ * @param {string[]} globs
300
+ * @param {{ cwd?: string }} [options]
301
+ * @returns {string[]}
302
+ */
303
+ export function findInstructionFiles(globs, { cwd = process.cwd() } = {}) {
304
+ const literalRoot = resolve(cwd);
305
+ const realRoot = realpathSync(literalRoot);
306
+ const seen = new Set();
307
+ for (const pattern of globs)
308
+ for (const name of globSync(pattern, {
309
+ cwd,
310
+ exclude: (entry) => entry === "node_modules",
311
+ })) {
312
+ // globSync returns absolute paths verbatim for an absolute pattern and
313
+ // cwd-relative names otherwise; joining an already-absolute name would
314
+ // double the prefix into a nonexistent path (the absolute-glob miss bug).
315
+ const absPath = isAbsolute(name) ? name : join(cwd, name);
316
+ if (keepContained(absPath, realRoot, literalRoot, pattern))
317
+ seen.add(absPath);
318
+ }
319
+ return [...seen];
320
+ }
321
+
322
+ /**
323
+ * Scan every instruction file matched by `globs` and return only those with
324
+ * findings, each path reported relative to `cwd`. Unreadable/missing files are
325
+ * skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
326
+ * @param {string[]} globs
327
+ * @param {{ cwd?: string }} [options]
328
+ * @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
329
+ */
330
+ export function scanInstructionFiles(globs, { cwd = process.cwd() } = {}) {
331
+ const out = [];
332
+ for (const file of findInstructionFiles(globs, { cwd })) {
333
+ let content;
334
+ try {
335
+ content = readFileSync(file, "utf-8");
336
+ } catch {
337
+ continue; // missing or unreadable
338
+ }
339
+ const findings = scanText(content);
340
+ if (findings.length > 0) out.push({ file: relative(cwd, file), findings });
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /**
346
+ * Atomically replace `absPath`'s contents with `data`, preserving `mode`.
347
+ *
348
+ * Writes to a sibling temp in the same directory, then `rename`s it over the
349
+ * original (same dir => same filesystem => the rename is atomic, not a
350
+ * cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to
351
+ * crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the
352
+ * path already exists — including an attacker-planted symlink at a guessable
353
+ * temp name — the open fails (EEXIST) and does NOT follow the link to clobber
354
+ * its target. On the rare collision we fail loud rather than retry into a
355
+ * different attacker-controlled path.
356
+ *
357
+ * Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the
358
+ * rename and the directory fd is `fsync`ed after it, so a power loss can't leave
359
+ * the renamed name pointing at unflushed/empty data or lose the rename itself.
360
+ * The EXACT `mode` is applied with `fchmod` (openSync's create mode is
361
+ * umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s
362
+ * the temp before rethrowing so no partial temp leaks. `tmpName` and `remove`
363
+ * are injectable fault-injection seams for tests (force a known temp path; drive
364
+ * a cleanup-unlink failure); production callers never pass them.
365
+ * @param {string} absPath
366
+ * @param {string} data
367
+ * @param {number} mode
368
+ * @param {() => string} [tmpName]
369
+ * @param {(path: string) => void} [remove]
370
+ */
371
+ export function atomicReplaceFile(
372
+ absPath,
373
+ data,
374
+ mode,
375
+ tmpName = () => `.${randomBytes(12).toString("hex")}.tmp`,
376
+ remove = unlinkSync,
377
+ ) {
378
+ const dir = dirname(absPath);
379
+ const tmp = join(dir, tmpName());
380
+ // Exclusive create: EEXIST (incl. a planted symlink at the temp name)
381
+ // propagates directly — no temp of ours exists yet, so nothing to clean up
382
+ // and we must never unlink the attacker's pre-existing path.
383
+ const fd = openSync(
384
+ tmp,
385
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
386
+ mode,
387
+ );
388
+ try {
389
+ // writeFileSync(fd, …) loops until every byte is written (a bare writeSync
390
+ // can short-write a large payload); it does not close the caller's fd.
391
+ writeFileSync(fd, data);
392
+ // Preserve the EXACT mode: openSync's create mode is umask-masked, fchmod is
393
+ // not, so this restores bits (e.g. group-write) the umask would have dropped.
394
+ fchmodSync(fd, mode);
395
+ // Flush the temp's bytes before the rename, else a crash can expose the
396
+ // renamed name pointing at empty/partial content.
397
+ fsyncSync(fd);
398
+ } catch (err) {
399
+ closeSync(fd);
400
+ try {
401
+ remove(tmp);
402
+ } catch {
403
+ // Best-effort cleanup only: rethrow the ORIGINAL failure below, not a
404
+ // secondary unlink error, so the real cause stays loud.
405
+ }
406
+ throw err;
407
+ }
408
+ closeSync(fd);
409
+ renameSync(tmp, absPath);
410
+ // fsync the DIRECTORY so the rename (a directory metadata change) is durable
411
+ // across a crash, not just the file's data blocks.
412
+ const dirFd = openSync(dir, constants.O_RDONLY);
413
+ try {
414
+ fsyncSync(dirFd);
415
+ } finally {
416
+ closeSync(dirFd);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Strip payload-capable invisible characters from `absPath` in place. Returns
422
+ * `true` when the file's bytes actually changed (a payload {@link scanText}
423
+ * flags was removed), `false` when {@link scanText} reports nothing, and `null`
424
+ * when scan flagged a payload but {@link stripInvisible} removes nothing — a
425
+ * fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed
426
+ * emoji-tag sequence the stripper keeps), so the caller must not treat it as
427
+ * cleaned. `true` means and only means "bytes changed".
428
+ *
429
+ * Contract (scan/clean coherence): clean strips exactly what scan flags. A
430
+ * write happens ONLY when `scanText` reports a finding, so the "scan, then
431
+ * clean what scan flagged" workflow never silently rewrites a file scan called
432
+ * clean. A handful of sub-threshold invisible chars (which scan ignores) are
433
+ * left untouched — by design, the scanner's definition of a payload is the
434
+ * single source of truth for what gets removed.
435
+ *
436
+ * Refuses to follow symlinks: instruction files must be regular files. The read
437
+ * fd is opened with `O_NOFOLLOW`, so a symlinked path (which could redirect the
438
+ * read/write to a target outside the tree) makes the OPEN itself fail — closing
439
+ * the lstat→open TOCTOU window a separate stat would leave, in which the path
440
+ * could be swapped to a symlink between the check and the read.
441
+ *
442
+ * Non-UTF-8 safety (O9): the file is read as raw BYTES and required to round-trip
443
+ * losslessly through UTF-8 before any rewrite. `readFileSync(…, "utf-8")`
444
+ * silently maps invalid bytes to U+FFFD, which a naive strip-and-rewrite would
445
+ * then persist file-wide — so a non-UTF-8 file fails loud and is left untouched.
446
+ *
447
+ * Lost-update / TOCTOU guard: the on-path file is re-checked against the fstat
448
+ * snapshot taken right after open (inode, size, mtime, and not-a-symlink) before
449
+ * the rename; a concurrent write or symlink swap between our read and our write
450
+ * fails loud rather than silently clobbering the other writer.
451
+ *
452
+ * The write is atomic (see {@link atomicReplaceFile}): stripped content goes to
453
+ * a temp file in the same directory which is then `rename`d over the original
454
+ * (preserving the original file mode), fsync'd for crash-safety.
455
+ *
456
+ * Throws if the file cannot be read or written (the caller decides whether an
457
+ * unwritable contaminated file is fatal or falls back to alerting).
458
+ * @param {string} absPath
459
+ * @param {(path: string) => import("node:fs").Stats} [lstat] injectable
460
+ * pre-rename recheck stat (fault-injection seam, mirrors
461
+ * {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent
462
+ * write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise
463
+ * unreachable from this fully-synchronous path. Defaults to `lstatSync`.
464
+ * @returns {boolean}
465
+ */
466
+ export function cleanFile(absPath, lstat = lstatSync) {
467
+ let fd;
468
+ try {
469
+ fd = openSync(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
470
+ } catch (err) {
471
+ // O_NOFOLLOW on a symlink fails ELOOP (some libc report EMLINK); surface
472
+ // the same "regular files only" contract the old lstat check did. Other
473
+ // errors (ENOENT/EACCES/…) propagate unchanged.
474
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
475
+ if (code === "ELOOP" || code === "EMLINK")
476
+ throw new Error(
477
+ `refusing to clean through a symlink (instruction files must be regular files): ${JSON.stringify(
478
+ absPath,
479
+ )}`,
480
+ { cause: err },
481
+ );
482
+ throw err;
483
+ }
484
+ try {
485
+ const before = fstatSync(fd);
486
+ if (!before.isFile())
487
+ throw new Error(
488
+ `refusing to clean a non-regular file (instruction files must be regular files): ${JSON.stringify(
489
+ absPath,
490
+ )}`,
491
+ );
492
+
493
+ // Read raw bytes and require a lossless UTF-8 round-trip (see O9 above).
494
+ const raw = readFileSync(fd);
495
+ const original = raw.toString("utf-8");
496
+ if (!Buffer.from(original, "utf-8").equals(raw))
497
+ throw new Error(
498
+ `refusing to clean a file that is not valid UTF-8 (round-trip mismatch would corrupt it): ${JSON.stringify(
499
+ absPath,
500
+ )}`,
501
+ );
502
+
503
+ // Scan is the SSOT for what counts as a payload: don't rewrite a file scan
504
+ // would not flag, even if stripInvisible would technically remove a char.
505
+ if (scanText(original).length === 0) return false;
506
+
507
+ const stripped = stripInvisible(original);
508
+ // Re-verify the on-path file against the open-time snapshot before writing:
509
+ // an inode/size/mtime change (or a swap to a symlink) means someone modified
510
+ // it under us, so fail loud rather than clobber their write (lost update).
511
+ const after = lstat(absPath);
512
+ if (
513
+ after.isSymbolicLink() ||
514
+ after.ino !== before.ino ||
515
+ after.size !== before.size ||
516
+ after.mtimeMs !== before.mtimeMs
517
+ )
518
+ throw new Error(
519
+ `instruction file changed between read and write, refusing to clobber (possible concurrent write or symlink swap): ${JSON.stringify(
520
+ absPath,
521
+ )}`,
522
+ );
523
+ // `before.mode` is the opened regular file's mode. A crash before the rename
524
+ // leaves the original intact; after it the new content is fully present.
525
+ atomicReplaceFile(absPath, stripped, before.mode);
526
+ return true;
527
+ } finally {
528
+ closeSync(fd);
529
+ }
530
+ }