agent-sanitizer 2.19.2 → 2.19.4

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.
@@ -9,13 +9,20 @@ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
10
  import {
11
11
  awaitLazyDependency,
12
+ safeErrMessage,
12
13
  hookgateMarkerPath,
14
+ HookEvent,
13
15
  isMain,
14
16
  lazyImport,
15
17
  markerIsTrusted,
16
18
  probeSetupAlive,
17
19
  writeFileNoFollow,
18
20
  } from "./lib/hook-io.mjs";
21
+ import {
22
+ registerFaultPolicy,
23
+ hookFaultOutcome,
24
+ writeFaultOutcome,
25
+ } from "./lib/hook-fault.mjs";
19
26
  import {
20
27
  ALERT_FILE,
21
28
  ALERT_ACK_FILE,
@@ -77,6 +84,69 @@ async function ensureSanitizerLoaded() {
77
84
  /* c8 ignore stop */
78
85
  }
79
86
 
87
+ const HOOK_NAME = "scan-invisible-chars";
88
+
89
+ /**
90
+ * The stderr line both posture arms share: what broke, and what it cost.
91
+ * @param {{ message: string }} ctx
92
+ * @returns {string}
93
+ */
94
+ function faultLine(ctx) {
95
+ return (
96
+ `${HOOK_NAME}: ${ctx.message}. Instruction files were NOT fully scanned ` +
97
+ "for hidden Unicode, so any payload in them reaches the model unvetted."
98
+ );
99
+ }
100
+
101
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). It has no
102
+ // stdout verdict channel — SessionStart cannot deny — so BOTH arms are stated
103
+ // explicitly rather than taking the shared additionalContext default: the only
104
+ // enforcement a SessionStart hook can reach is the cross-hook alert, which makes
105
+ // the PreToolUse gate ask once on the next tool call.
106
+ registerFaultPolicy(HOOK_NAME, {
107
+ event: HookEvent.SESSION_START,
108
+ guarded: "instruction files",
109
+ open: (ctx) => ({
110
+ stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
111
+ exitCode: 1,
112
+ }),
113
+ closed: (ctx) => ({
114
+ stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
115
+ exitCode: 1,
116
+ armAlert: true,
117
+ }),
118
+ });
119
+
120
+ /**
121
+ * Render this hook's fault under the declared posture, and return the text (if
122
+ * any) that must ride in the cross-hook alert so a later gate carries the
123
+ * posture this hook cannot express itself.
124
+ * @param {unknown} err
125
+ * @returns {string[]}
126
+ */
127
+ function reportFault(err) {
128
+ const outcome = hookFaultOutcome(HOOK_NAME, err);
129
+ process.exitCode = writeFaultOutcome(outcome);
130
+ return outcome.armAlert ? [/** @type {string} */ (outcome.stderr)] : [];
131
+ }
132
+
133
+ /**
134
+ * Persist the accumulated alert text for the PreToolUse gate, or leave the alert
135
+ * absent when there is nothing to surface.
136
+ *
137
+ * ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
138
+ * writeFileSync would follow a co-tenant-planted symlink and overwrite an
139
+ * arbitrary file this uid owns. Create it symlink-refusingly (see
140
+ * writeFileNoFollow); the gate treats an absent alert as "nothing to surface",
141
+ * so a lost race degrades safely rather than to a hijacked write.
142
+ * @param {string[]} parts
143
+ * @returns {void}
144
+ */
145
+ function persistAlert(parts) {
146
+ if (parts.length === 0) return;
147
+ writeFileNoFollow(ALERT_FILE, parts.join("\n") + "\n");
148
+ }
149
+
80
150
  // Decoder
81
151
 
82
152
  /**
@@ -249,50 +319,118 @@ export { formatReport };
249
319
 
250
320
  // Main (skip when imported for testing)
251
321
 
252
- // Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
253
- // which in-process tests can't observe, so every mutant here is unkillable by
254
- // construction (same boundary as the c8-ignored regions below). The exported
255
- // scanFile/decodeRun above carry the real, mutation-tested logic.
256
322
  /**
257
- * Scan every instruction file under the project for invisible-char findings.
258
- * @returns {Array<{file: string, findings: ReturnType<typeof scanFile>}>}
323
+ * Scan every instruction file under the project, ACCOUNTING for every target
324
+ * the finder returned: `scanned + skipped.length === targets.length`, always.
325
+ *
326
+ * The accounting is the point. This scan is the only thing standing between a
327
+ * poisoned `CLAUDE.md` and a session that loads it as instructions, and its
328
+ * caller announces "clean" on the trace channel — the channel that exists so a
329
+ * MISSING announcement is loud. A per-file failure swallowed into an empty
330
+ * findings list turns "we could not read this file" into "this file is fine",
331
+ * which is the one lie this hook must never tell. So a file that cannot be read
332
+ * is REPORTED as unscanned, not dropped.
333
+ *
334
+ * ANY errno is a skip; only a non-filesystem throw propagates. The split is
335
+ * between "this file could not be read" (report it and keep scanning) and "this
336
+ * code is broken" (a TypeError from an unloaded binding — nothing here can be
337
+ * trusted, so it goes to the caller's declared failure posture). Catching only
338
+ * ENOENT would invert the enforcement: one EACCES target would discard the
339
+ * result for EVERY other instruction file, leaving them unscanned and
340
+ * un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
341
+ * nothing — so the SUSPICIOUS failure would get weaker enforcement than the
342
+ * benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
343
+ * split {@link autoCleanFindings} uses.
344
+ * @param {string} [dir] project root to scan (injectable for tests)
345
+ * @returns {{
346
+ * targets: string[],
347
+ * scanned: number,
348
+ * findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
349
+ * skipped: Array<{file: string, reason: string}>,
350
+ * }}
259
351
  */
260
- function scanProject() {
352
+ export function scanProject(dir = PROJECT_DIR) {
261
353
  const targets = [
262
354
  ...new Set([
263
- ...findInstructionFiles(PROJECT_DIR),
264
- ...findMdFiles(join(PROJECT_DIR, ".claude")),
355
+ ...findInstructionFiles(dir),
356
+ ...findMdFiles(join(dir, ".claude")),
265
357
  ]),
266
358
  ];
267
- const allFindings = [];
359
+ const findings = [];
360
+ const skipped = [];
361
+ let scanned = 0;
268
362
  for (const file of targets) {
363
+ let fileFindings;
269
364
  try {
270
- const findings = scanFile(file);
271
- if (findings.length > 0) {
272
- allFindings.push({ file: relative(PROJECT_DIR, file), findings });
273
- }
274
- } catch {
275
- // File doesn't exist or unreadable
365
+ fileFindings = scanFile(file);
366
+ } catch (err) {
367
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
368
+ throw err;
369
+ // safeErrMessage, not errMessage: this reason is rendered into stderr and
370
+ // into ALERT_FILE, and an errno message embeds the absolute path globbed
371
+ // out of a possibly-hostile repo — a filename carrying ANSI or invisible
372
+ // bytes would otherwise reach the operator's terminal raw.
373
+ skipped.push({ file: relative(dir, file), reason: safeErrMessage(err) });
374
+ continue;
276
375
  }
376
+ scanned++;
377
+ if (fileFindings.length > 0)
378
+ findings.push({ file: relative(dir, file), findings: fileFindings });
277
379
  }
278
- return allFindings;
380
+ return { targets, scanned, findings, skipped };
279
381
  }
280
382
 
383
+ /**
384
+ * The report for targets the scan could not read. Rendered into the alert the
385
+ * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
386
+ * checkpoint rather than as silence.
387
+ * @param {Array<{file: string, reason: string}>} skipped
388
+ * @returns {string}
389
+ */
390
+ export function formatSkipped(skipped) {
391
+ return [
392
+ "",
393
+ "━━━ INSTRUCTION FILES NOT SCANNED ━━━",
394
+ "",
395
+ "These files load as project instructions but could NOT be read, so they",
396
+ "were never checked for hidden Unicode. Treat their content as unvetted.",
397
+ "",
398
+ ...skipped.map(({ file, reason }) => ` ${file}: ${reason}`),
399
+ "",
400
+ ].join("\n");
401
+ }
402
+
403
+ // Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
404
+ // which in-process tests can't observe, so every mutant here is unkillable by
405
+ // construction (same boundary as the c8-ignored regions below). The exported
406
+ // scanFile / decodeRun / scanProject above carry the real, tested logic.
281
407
  /**
282
408
  * The hook's CLI: scan the instruction files, auto-clean what it can, persist
283
409
  * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
284
410
  * (which must claim the CLI slot before this module loads) can run the exact
285
411
  * same scan instead of duplicating it.
286
- * @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
287
- * this scan announces engagement; a host with its own trace channel passes its
288
- * sink so the announcement lands where its detector reads (see lib/trace.mjs).
412
+ * @param {{
413
+ * trace?: import("./lib/trace.mjs").TraceFn,
414
+ * scan?: () => ReturnType<typeof scanProject>,
415
+ * }} [opts] `trace` is where this scan announces engagement; a host with its
416
+ * own trace channel passes its sink so the announcement lands where its
417
+ * detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
418
+ * FAULT path below — a scanner that throws something other than an errno, i.e.
419
+ * a bug — is drivable end to end; no filesystem state can force it, and an
420
+ * untested fault path is how a posture goes missing in the first place.
289
421
  * @returns {Promise<void>}
290
422
  */
291
- export async function cliMain({ trace: sink = trace } = {}) {
423
+ export async function cliMain({ trace: sink = trace, scan: runScan } = {}) {
292
424
  // Bound best-effort: the announcements below run BEFORE the auto-clean and
293
425
  // the alert write, with no catch above them, so a throwing host sink would
294
426
  // abort the scan silently (see bestEffortTrace).
295
427
  const emitTrace = bestEffortTrace(sink);
428
+ // Everything the PreToolUse gate must surface this session, written once at
429
+ // the end: an incomplete scan and an uncleanable file are independent reasons
430
+ // to arm the gate, and two separate writes would have the second clobber the
431
+ // first.
432
+ /** @type {string[]} */
433
+ const alertParts = [];
296
434
  /* c8 ignore start -- fail-closed module-load guard: only reachable when the
297
435
  agent-sanitizer import above failed, which can't be simulated in the
298
436
  spawned-subprocess CLI run the tests observe. */
@@ -301,11 +439,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
301
439
  // the trace channel — a scan that never ran is otherwise invisible, and the
302
440
  // downstream PreToolUse sanitize gate then passes cleanly all session.
303
441
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
304
- process.stderr.write(
305
- "scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
306
- "installed and session-setup did not finish in time); instruction " +
307
- "files were NOT scanned for hidden Unicode. Run `pnpm install`.\n",
442
+ // Through the shared posture table, NOT a bare exit(1): this hook took an
443
+ // advisory posture unconditionally, so an operator who pinned
444
+ // AGENT_SANITIZER_FAIL_OPEN=0 got only a warning on the one hook guarding
445
+ // session-start ingress, and the PreToolUse gate then passed cleanly for
446
+ // the rest of the session. The closed arm arms the cross-hook alert, which
447
+ // is the only channel a SessionStart hook has to make a later gate ask.
448
+ alertParts.push(
449
+ ...reportFault(
450
+ new Error(
451
+ "agent-sanitizer failed to load (node deps not installed and " +
452
+ "session-setup did not finish in time); run `pnpm install`",
453
+ ),
454
+ ),
308
455
  );
456
+ persistAlert(alertParts);
309
457
  process.exit(1);
310
458
  }
311
459
  /* c8 ignore stop */
@@ -320,22 +468,61 @@ export async function cliMain({ trace: sink = trace } = {}) {
320
468
  }
321
469
  }
322
470
 
323
- const allFindings = scanProject();
471
+ // Only a non-errno throw reaches here — a bug in the scanner, not a file it
472
+ // could not read (those are accounted for in `skipped`). It is a fault of THIS
473
+ // hook, so it renders through the same posture table as every other hook's
474
+ // fault instead of aborting with no announcement at all.
475
+ let scan;
476
+ try {
477
+ scan = (runScan ?? scanProject)();
478
+ } catch (err) {
479
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
480
+ alertParts.push(...reportFault(err));
481
+ persistAlert(alertParts);
482
+ return;
483
+ }
484
+ const { findings: allFindings, skipped, scanned } = scan;
324
485
 
325
- if (allFindings.length === 0) {
486
+ // "clean" is a claim about EVERY target, so it may only be made when every
487
+ // target was read. A scan that could not read one says "partial" and arms the
488
+ // gate: an unread instruction file is UNVETTED context, not absent findings.
489
+ if (skipped.length > 0) {
490
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
491
+ outcome: "partial",
492
+ scanned,
493
+ skipped: skipped.length,
494
+ files: allFindings.length,
495
+ });
496
+ const notice = formatSkipped(skipped);
497
+ process.stderr.write(notice + "\n");
498
+ alertParts.push(notice);
499
+ } else if (allFindings.length === 0) {
326
500
  emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
327
501
  return;
502
+ } else {
503
+ emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
504
+ outcome: "found",
505
+ files: allFindings.length,
506
+ });
328
507
  }
329
- emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
330
- outcome: "found",
331
- files: allFindings.length,
332
- });
333
508
 
334
- // Auto-clean contaminated files so the session proceeds without blocking
335
- // every tool call; the gate hook is the fallback when cleaning fails.
509
+ if (allFindings.length > 0)
510
+ alertParts.push(...autoCleanFindings(allFindings, PROJECT_DIR));
511
+ persistAlert(alertParts);
512
+ }
513
+
514
+ /**
515
+ * Auto-clean the contaminated files so the session proceeds without blocking
516
+ * every tool call, and return the alert text for whatever could not be cleaned
517
+ * (empty when everything was). The gate hook is the fallback for the rest.
518
+ * @param {Array<{file: string, findings: ReturnType<typeof scanFile>}>} allFindings
519
+ * @param {string} dir the root the finding paths are relative to
520
+ * @returns {string[]}
521
+ */
522
+ function autoCleanFindings(allFindings, dir) {
336
523
  let cleaned = 0;
337
524
  for (const { file } of allFindings) {
338
- const absPath = join(PROJECT_DIR, file);
525
+ const absPath = join(dir, file);
339
526
  try {
340
527
  const original = readFileSync(absPath, "utf-8");
341
528
  const stripped = stripInvisible(original);
@@ -344,15 +531,21 @@ export async function cliMain({ trace: sink = trace } = {}) {
344
531
  cleaned++;
345
532
  }
346
533
  /* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
347
- } catch {
348
- // Unreadable or unwritable: the file stays contaminated and falls into the
349
- // alert path below, which hands it to the PreToolUse gate.
534
+ } catch (err) {
535
+ // Narrowed to filesystem errnos, and the reason is REPORTED rather than
536
+ // swallowed: an unwritable file legitimately falls through to the alert
537
+ // path below, but a throw from stripInvisible is a bug in the sanitizer
538
+ // and must not be laundered into "this file resisted cleaning".
539
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === undefined)
540
+ throw err;
541
+ process.stderr.write(
542
+ `scan-invisible-chars: could not clean ${file}: ${safeErrMessage(err)}\n`,
543
+ );
350
544
  }
351
545
  /* c8 ignore stop */
352
546
  }
353
547
 
354
548
  const report = formatReport(allFindings);
355
-
356
549
  if (cleaned === allFindings.length) {
357
550
  process.stderr.write(
358
551
  report +
@@ -363,16 +556,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
363
556
  "suspicion, and restart the session if in doubt. Future sessions load " +
364
557
  "the cleaned files.\n",
365
558
  );
366
- /* c8 ignore start -- only reachable when the write catch above fires */
367
- } else {
368
- process.stderr.write(report + "\n");
369
- // ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
370
- // writeFileSync would follow a co-tenant-planted symlink and overwrite an
371
- // arbitrary file this uid owns. Create it symlink-refusingly (see
372
- // writeFileNoFollow); the PreToolUse gate treats an absent alert as "nothing
373
- // to surface", so a lost race degrades safely rather than to a hijacked write.
374
- writeFileNoFollow(ALERT_FILE, report + "\n");
559
+ return [];
375
560
  }
561
+ /* c8 ignore start -- only reachable when the write catch above fires */
562
+ process.stderr.write(report + "\n");
563
+ return [report];
376
564
  /* c8 ignore stop */
377
565
  }
378
566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.19.2",
3
+ "version": "2.19.4",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/ansi.mjs ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The ONE ANSI grammar: the raw control-introducer charset and the tokenizer
3
+ * every consumer scans with.
4
+ *
5
+ * Two modules need this grammar and they cannot import each other —
6
+ * `layer1.mjs` imports `invisible.mjs`, so `invisible.mjs` (which owns the
7
+ * public `isSgrOnly` / `SGR_RE`) must not import back. Before this module the
8
+ * grammar was therefore written out twice with DIFFERENT param rules
9
+ * (`invisible.mjs`'s SGR regex accepted any digit run, `layer1.mjs`'s CSI
10
+ * branch capped each parameter at four digits), and the introducer charset
11
+ * three times. The looser copy suppressed the operator warning for a sequence
12
+ * the stripper could not match: `ESC[12345m` read as "display-only colour"
13
+ * while `[12345m` was spliced into the model's view as visible text. One
14
+ * tokenizer, one charset, consumed by both — the disagreement cannot recur.
15
+ *
16
+ * Same precedent (and same reason) as `cf-charset.mjs`: a dependency-free leaf
17
+ * module both layers read from.
18
+ */
19
+
20
+ // Raw control introducers that must not survive Layer 1: 7-bit ESC (U+001B) and
21
+ // the entire 8-bit C1 control block (U+0080-U+009F) — which includes CSI
22
+ // (U+009B), the string introducers DCS/SOS/OSC/PM/APC
23
+ // (U+0090/0098/009D/009E/009F), and ST (U+009C). Gating the whole C1 block, not
24
+ // just the introducers the sequence grammar below names, fails closed: a
25
+ // DCS/SOS/PM/APC string the grammar does not consume still loses its introducer
26
+ // and terminator, so no terminal can hide-render its body as a control payload.
27
+ //
28
+ // A SOURCE STRING, not a literal: three call sites need it with different flags
29
+ // (`g` for the Layer-1 sweep, unflagged for the prompt gate, `g` again to drive
30
+ // the scan below), and spelling the class out at each site is how the three
31
+ // copies came to spell the same byte two different ways — which defeats a
32
+ // grep-based drift check as well. Building from `\uXXXX` escapes keeps every
33
+ // raw control byte out of the source (no `no-control-regex` disable needed).
34
+ export const CONTROL_INTRODUCER_SOURCE = "[\\u001b\\u0080-\\u009f]";
35
+
36
+ // SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
37
+ // params are [0-9;:]* and the final byte is `m`, so a match can only restyle
38
+ // text — never reposition the cursor, erase, or smuggle an OSC string. `:` is
39
+ // included alongside `;` because ITU T.416 colon-separated SGR sub-parameters
40
+ // (truecolor `ESC[38:2:255:0:0m`, as emitted by tmux/kitty/mintty) are pure
41
+ // display-only SGR too. A SGR sequence has TWO encodings: the 7-bit `ESC [ … m`
42
+ // and the 8-bit C1 form where a single U+009B (CSI) replaces `ESC [`; both must
43
+ // be recognized, or a C1-introduced `U+009B 31m … 0m` is pure color yet is
44
+ // misread as a non-SGR payload.
45
+ const SGR_SOURCE = "(?:\\u001b\\[|\\u009b)[0-9;:]*m";
46
+
47
+ /**
48
+ * Public alias kept for compatibility (re-exported by `invisible.mjs` and the
49
+ * package root). It is now DERIVED: {@link scanAnsi} classifies a token as SGR
50
+ * by testing the token's own text against this exact source, so the predicate
51
+ * and the regex can no longer describe different languages.
52
+ */
53
+ export const SGR_RE = new RegExp(SGR_SOURCE, "g");
54
+
55
+ // The same language, anchored — the SGR/CSI discriminator for a token the
56
+ // scanner has already delimited.
57
+ const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
58
+
59
+ // Private parameter-prefix and intermediate bytes that may follow an
60
+ // introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
61
+ // the 7-bit `ESC [` CSI introducer's bracket itself.
62
+ const CSI_INTRO_RE = /[[()#;?]/;
63
+
64
+ // ECMA-48 parameter bytes.
65
+ const CSI_PARAM_RE = /[0-9;:]/;
66
+
67
+ // ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
68
+ // PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
69
+ // must not eat trailing visible digits (`ESC[2024 report` is NOT `ESC[` +
70
+ // final-byte `2`; it is an incomplete intro whose ESC the residual sweep
71
+ // removes, leaving "2024 report" intact). `<=>?` (0x3C-0x3F) are private
72
+ // PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
73
+ // private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
74
+ // final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
75
+ const CSI_FINAL_RE = /[A-PR-TZcf-nqrty~]/;
76
+
77
+ const ESC = 0x1b;
78
+ const CSI_C1 = 0x9b;
79
+ const ST_C1 = 0x9c;
80
+ const OSC_C1 = 0x9d;
81
+ const BEL = 0x07;
82
+
83
+ /** The four things an introducer can turn out to be. */
84
+ export const TOKEN_KIND = Object.freeze({
85
+ /** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
86
+ SGR: "sgr",
87
+ /** Any other complete CSI / two-byte escape (cursor move, erase, charset). */
88
+ CSI: "csi",
89
+ /** An OSC string: introducer, body and terminator as one unit. */
90
+ OSC: "osc",
91
+ /** An introducer that starts no sequence the grammar recognizes. */
92
+ ORPHAN: "orphan-introducer",
93
+ });
94
+
95
+ /**
96
+ * @typedef {object} AnsiToken
97
+ * @property {number} start Index of the introducer.
98
+ * @property {number} end Index one past the last character of the token.
99
+ * @property {string} kind One of {@link TOKEN_KIND}.
100
+ */
101
+
102
+ /**
103
+ * End index of the OSC string starting at `start`, or -1 if no OSC introducer
104
+ * is there.
105
+ *
106
+ * An OSC (Operating System Command) string is `<introducer> body <terminator>`:
107
+ * a title, a clickable-hyperlink URL, a clipboard write — i.e. attacker-
108
+ * controlled PAYLOAD TEXT. Consuming the introducer alone would leave that
109
+ * payload in the model's view, so the whole string is one token. Three ways it
110
+ * can end:
111
+ * 1. a real terminator — ST (`ESC\` or the 8-bit C1 ST U+009C) or the legacy
112
+ * BEL — which is consumed with the body.
113
+ * 2. an ABORT: per ECMA-48/xterm a bare ESC (one not forming ST) drops the
114
+ * terminal out of the OSC string, and a nested C1 OSC introducer likewise
115
+ * starts something new. The token ends BEFORE that byte so the scan
116
+ * re-reads it as its own sequence — without this, an interior ESC deleted
117
+ * the rest of the document via case 3.
118
+ * 3. end of input, for a genuinely unterminated string: fail closed and drop
119
+ * everything from the introducer on, so no OSC body survives.
120
+ * @param {string} text
121
+ * @param {number} start
122
+ * @returns {number}
123
+ */
124
+ function scanOsc(text, start) {
125
+ const code = text.charCodeAt(start);
126
+ let i = -1;
127
+ if (code === OSC_C1) i = start + 1;
128
+ if (code === ESC && text[start + 1] === "]") i = start + 2;
129
+ if (i < 0) return -1;
130
+ for (; i < text.length; i++) {
131
+ const byte = text.charCodeAt(i);
132
+ if (byte === BEL || byte === ST_C1) return i + 1;
133
+ if (byte === ESC) return text[i + 1] === "\\" ? i + 2 : i;
134
+ if (byte === OSC_C1) return i;
135
+ }
136
+ return text.length;
137
+ }
138
+
139
+ /**
140
+ * End index of the CSI / two-byte escape sequence starting at `start`, or -1
141
+ * when the introducer completes no sequence.
142
+ *
143
+ * Single-pass and greedy: intro bytes, then parameter bytes, then exactly one
144
+ * final byte. The previous regex form had to BOUND the intro run ({0,12})
145
+ * because `;` lives in both the intro and parameter classes, so an unbounded
146
+ * run let a `;#;#…` string be split between the two quantifiers — quadratic
147
+ * backtracking (CodeQL js/polynomial-redos). A hand-written scanner never
148
+ * backtracks, so the bound is gone and the scan is linear by construction.
149
+ * @param {string} text
150
+ * @param {number} start
151
+ * @returns {number}
152
+ */
153
+ function scanCsi(text, start) {
154
+ const code = text.charCodeAt(start);
155
+ if (code !== ESC && code !== CSI_C1) return -1;
156
+ let i = start + 1;
157
+ while (i < text.length && CSI_INTRO_RE.test(text[i])) i++;
158
+ while (i < text.length && CSI_PARAM_RE.test(text[i])) i++;
159
+ if (i < text.length && CSI_FINAL_RE.test(text[i])) return i + 1;
160
+ return -1;
161
+ }
162
+
163
+ // Drives the scan: jumping introducer-to-introducer keeps the common case (text
164
+ // with no escapes at all) a single native regex scan rather than a per-character
165
+ // JS loop.
166
+ const INTRODUCER_SCAN_RE = new RegExp(CONTROL_INTRODUCER_SOURCE, "g");
167
+
168
+ /**
169
+ * Tokenize every raw control introducer in `text`.
170
+ *
171
+ * Every introducer yields exactly one token — an ORPHAN when it starts nothing
172
+ * the grammar recognizes — so "which introducers are in this text" and "which
173
+ * sequences are in this text" are answered by the same scan. That is what lets
174
+ * the stripper (splice every non-orphan token, then sweep) and the SGR-only
175
+ * predicate (every token is SGR) agree by construction.
176
+ *
177
+ * Tokens are disjoint and ordered by `start`; each `end` is strictly greater
178
+ * than its `start`, so the scan always advances.
179
+ * @param {string} text
180
+ * @returns {AnsiToken[]}
181
+ */
182
+ export function scanAnsi(text) {
183
+ /** @type {AnsiToken[]} */
184
+ const tokens = [];
185
+ INTRODUCER_SCAN_RE.lastIndex = 0;
186
+ let match;
187
+ while ((match = INTRODUCER_SCAN_RE.exec(text)) !== null) {
188
+ const start = match.index;
189
+ const oscEnd = scanOsc(text, start);
190
+ const csiEnd = oscEnd < 0 ? scanCsi(text, start) : -1;
191
+ let end = start + 1;
192
+ /** @type {string} */
193
+ let kind = TOKEN_KIND.ORPHAN;
194
+ if (oscEnd >= 0) {
195
+ end = oscEnd;
196
+ kind = TOKEN_KIND.OSC;
197
+ } else if (csiEnd >= 0) {
198
+ end = csiEnd;
199
+ kind = SGR_ANCHORED_RE.test(text.slice(start, csiEnd))
200
+ ? TOKEN_KIND.SGR
201
+ : TOKEN_KIND.CSI;
202
+ }
203
+ tokens.push({ start, end, kind });
204
+ INTRODUCER_SCAN_RE.lastIndex = end;
205
+ }
206
+ return tokens;
207
+ }
@@ -38,8 +38,12 @@
38
38
  *
39
39
  * ORDERING: the soundness argument assumes no later layer erases code points
40
40
  * from the same field, which would let an unmapped glyph the gate relied on
41
- * disappear after the decision. Layer 4 runs before sanitizeAuthoredContent on
42
- * Bash.command; keep it there.
41
+ * disappear after the decision — a zero-width run padded into a token suppresses
42
+ * the fold, and the erasing layer then removes the very evidence for skipping it.
43
+ * This fold does NOT run last: on Bash.command the invisible-char strip follows
44
+ * it. A caller that composes the two is therefore responsible for re-running
45
+ * this fold on the post-erasure text until it reports nothing, which is what the
46
+ * hook driver in claude-hooks/lib/layer-pipeline.mjs does.
43
47
  *
44
48
  * Genuine non-confusable non-ASCII (accented Latin, CJK, emoji) is untouched
45
49
  * regardless, since a faithful scanner does not flag it.