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.
- package/README.md +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
|
@@ -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
|
|
258
|
-
*
|
|
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(
|
|
264
|
-
...findMdFiles(join(
|
|
355
|
+
...findInstructionFiles(dir),
|
|
356
|
+
...findMdFiles(join(dir, ".claude")),
|
|
265
357
|
]),
|
|
266
358
|
];
|
|
267
|
-
const
|
|
359
|
+
const findings = [];
|
|
360
|
+
const skipped = [];
|
|
361
|
+
let scanned = 0;
|
|
268
362
|
for (const file of targets) {
|
|
363
|
+
let fileFindings;
|
|
269
364
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
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
|
|
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 {{
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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(
|
|
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
|
-
//
|
|
349
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/confusables.mjs
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
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.
|