llm-security/tests/golden
Kjell Tore Guttormsen b1ba1fbdc6 refactor(llm-security): v8 Phase 5 step 4 - swap codepoint tables to commons
First consumer swap of step 4. ZERO_WIDTH_CHARS (5), the Unicode Tag range,
BIDI_CHARS (9) and HOMOGLYPH_MAP (28) stop being hardcoded constants in
unicode-scanner.mjs and string-utils.mjs and are built from the vendored
commons artifact codepoints/carriers.json by the new lib/codepoints.mjs.

Started here rather than at injection-patterns, which the plan ordered first:
that table is the one table that cannot be loaded verbatim (the
hybrid-xss:script-tag divergence is directional, and loading the lexicon as-is
would reverse the 90f576f recall fix). The codepoint tables were measured
byte-equal to the source constants BEFORE the swap - same members, same
values, same insertion order on HOMOGLYPH_MAP - so they load verbatim.

Proof the swap is content-preserving: the golden dump differs in exactly one
record, the sha256 of string-utils.mjs, which changes by construction when a
table leaves the file. All 83 regex records and the
table:string-utils:HOMOGLYPH_MAP digest are byte-identical, and
reference-run.json is unchanged at 61/61. patterns.json is re-blessed for the
file digest alone.

The gate is proven red-capable against the SUBJECT, both directions:
- dropping U+00AD from the vendored zero_width table fails the new
  codepoints gate by name, twice;
- altering one homoglyph value reddens the golden table digest AND a
  behavioural homoglyph test.
That second direction is a property the swap creates rather than preserves:
the golden gate now transitively pins the vendored commons data, where before
it pinned a source literal and a commons mutation was invisible to it.

NOT ported: commons carries cyrillic_confusables (13), and unicode-scanner.mjs
declares a set by that name - but nothing reads it. The homoglyph-mixing
detector tests isCyrillic(cp), the whole U+0400-U+04FF block. Loading it would
move dead data into the load path, so the dead const stays where it is and is
recorded instead. The recorded v8.x-B i/x drift between that set and the
lexicon class is therefore latent, not live. commons' private_use table has no
constant behind it here at all.

Graceful-empty is kept deliberately: codepoints.mjs is on string-utils'
import path and hooks import string-utils in fresh per-tool-call processes, so
a module-load throw would break the tool call rather than degrade the scan.
The loud half is the test, which asserts exact per-table counts through the
real default commons root - the same shape as the lexicon load-assertion.

Drive-by, unavoidable: the deleted JSDoc carried the "~25 entries" claim for a
28-entry table (v8.x-C). It needed a re-bless of the same file digest this
swap already forces, so it closes here at no extra cost.

Suite 2158 -> 2164, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7XEEFrAJsREqa9N4tpfm8
2026-08-10 21:28:09 +02:00
..
patterns.json refactor(llm-security): v8 Phase 5 step 4 - swap codepoint tables to commons 2026-08-10 21:28:09 +02:00
README.md fix(llm-security): golden gate - coverage block measured something else 2026-08-09 13:06:45 +02:00
reference-run.json fix(llm-security): golden gate - coverage block measured something else 2026-08-09 13:06:45 +02:00
suite-counts.json test(llm-security): v8 Phase 5 step 1 - golden baseline before any table swap 2026-08-09 12:57:51 +02:00

Golden baseline — v8 Phase 5

Reference artifacts recorded before any commons extraction, so that each table-by-table swap in Phase 5 can be proven behaviour-preserving (or rolled back). Generated and checked by one code path:

node scripts/golden-baseline.mjs            # check only, exits 1 on drift
node scripts/golden-baseline.mjs --write    # (re-)bless
node scripts/golden-baseline.mjs --write --suite   # also refresh suite counts (slow)

The gate is tests/lib/golden-baseline.test.mjs.

The artifacts

File What it pins Why that layer exists
patterns.json .source + .flags of every RegExp reachable from the walked modules' exports After a swap a pattern is new RegExp(jsonString, flags). The plan's named hazard — JSON backslash-doubling — is visible only on the compiled object.
↳ table records key/value digest of HOMOGLYPH_MAP, TYPOSQUAT_SUSPICIOUS_TOKENS, SEVERITY, the four OWASP maps Most of what Phase 4 moves is not a regex. A regex-only dump is blind to a broken homoglyph or OWASP-map swap, i.e. blind to the bulk of the payload.
↳ file records sha256 of the five source files in the moving set The completeness layer, complete by construction: it covers regexes inlined in function bodies (NAMED at string-utils.mjs:291, the BIDI/tag/PUA ranges at 357404) that no export walk can reach.
reference-run.json the 61 showcase payloads through the real hook entry points, plus a static reachability probe Byte identity over a corpus that trips a handful of patterns would prove almost nothing, so reachability is recorded and unreachable patterns are listed by name.
suite-counts.json per-file pass/fail, each file run alone A total is unattributable, and npm test runs files concurrently where three timing-sensitive files flake. Running each alone is the only reproducible recording.

Why there is no regex enumerator

The obvious design — parse the sources and enumerate every regex literal — was rejected. It needs a JS parser this zero-dependency repo does not have, and a lexical approximation is contaminated by comments and division (severity.mjs scores 4 "regexes" that way and exports none). The two-layer split — export walk for what the scanners actually use, file digest for everything else — answers the same question without a parser.

During a swap

A diff here means the swap changed observable behaviour. Roll the swap back. --write is for deliberately re-blessing a change you have already decided is correct, not for making the gate quiet.

Known coverage gaps — read before quoting a number

coverage is static reachability, NOT observed coverage. It probes the 61 payload strings against the 83 injection patterns in-process: "if you threw every payload string at every regex, how many would match?" It does not measure what the 61 hook invocations evaluated — the pre-bash-destructive payloads never reach injection-patterns at all, yet their strings are in the probe set and can mark a pattern reachable. 47/83 is therefore an upper bound on what the corpus could protect, not a measurement of what it did.

The block is named kind: "static-reachability" and carries that caveat in a note field; the gate asserts both, so the honest label cannot be dropped quietly.

Two gaps follow:

  • 36 unreachable patterns, listed by key under coverage.unreachablePatterns. A swap that breaks one of those is caught by the pattern dump only. Closing it means growing the conformance corpus (Phase 5 step 5).
  • The four OWASP maps have no behavioural coverage at all. They are scanner-side and no hook in this corpus reaches them. They were the reason the dump was widened to table records, and the digest is their only protection — do not read the reference run as backing them.

corpusContains says a payload contains a homoglyph or is altered by normalizeForScan. It does not say the run folded one.