llm-security-commons/CLAUDE.md
Kjell Tore Guttormsen e6ca5ae5ee feat(active-content): the seventh case, and the classifier it needed came with it
Adopting `active:raw-html-link` was one id. Publishing it honestly was the whole
of `active_tag_class` — one function, three branches, no way to state the split
without the no-URL narrowing and the 0.6.0 external-target rule. On the old
predicate a bare `</a>` is active by name, so a consumer implementing from the
hybrid would emit the new label where the seed runtime emits nothing.

The file is now two pins, stated as two: v0.3.4/0bf0729 everywhere except the
raw-HTML classifier, v0.7.0/be9759b there. The drift between them was measured
field by field against the imported module rather than assumed, after stripping
inline-flag rendering and applying the file's own declared quote normalisation
so a spelling difference could not masquerade as drift. Exactly one published
field had moved, and not the one this release was about: `html.active_tags`
carried the MUTATOR's 23-name set where the gate means the SCANNER's 22. Correct
at the 0.3.4 pin, wrong from 0.6.0 on. Kept as `html.mutator_tags`.

The sweep covered 93 cases, not the 6 obvious ones. The narrowing can silence an
`active:` finding inside the `observed_out_of_scope` evidence of a LEXICON case,
and that field is guarded by no test anywhere — stale entries there survive
forever. One case moved: html-obfuscation__aria-label, whose `<a aria-label=…>`
carries no URL attribute. Its fixture is deliberately not rewritten; the residue
is true at the commit `measurement` pins, and rewriting one of 83 would leave two
commits under a header naming one. Recorded, dated and pinned in the manifest.

The strongest check is not the digest: the checker rebuilds the published
classifier from the JSON alone, importing nothing from the runtime, and
differential-tests it against `active_tag_class` over 42 probe tags. 0
disagreements. That is what licenses shipping a classifier as data.

No `aliases.llm_security` published, on this file or on carriers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTTaT4quwNPwBYVqmgAt8t
2026-08-13 21:43:27 +02:00

11 KiB

llm-security-commons

Kontekst

Runtime-nøytral kjerne for LLM/agent-sikkerhetsdeteksjon: detektor-data, normative kontrakter og en conformance-korpus som flere uavhengige runtimes kan kjøre mot og få identisk verdikt fra. Repoet er delt kjerne, ikke et produkt.

Kjente konsumenter (vendorer dette repoet, endrer det ikke):

  • llm-security — Claude Code-plugin, Node/ESM-scannere.
  • et Python-guard-repo — samme deteksjon i en annen runtime.
  • en wiki/advisory-flate — konsumerer samme lexicon og mapping.

Å dele én identisk kjerne er hele poenget: to implementasjoner som gir ulikt verdikt på samme input er per definisjon en bug i én av dem — ikke en meningsforskjell.

Charter (HARD — bryter du denne, er endringen feil uansett hvor god den er)

Ingen engine-kode. Ingenting her kjører.

  • Ingen .mjs, .js, .ts, .py, .sh som implementerer deteksjon, scanning, normalisering, scoring eller I/O.
  • Ingen package.json, pyproject.toml, lockfiler, dependencies, build-steg.
  • Ingen import fra — eller kjennskap til — noe rammeverk, SDK eller runtime.
  • Ingen nettverk, ingen modellkall, ingen tidsavhengighet, ingen tilfeldighet. Alt her er offline og deterministisk.
  • Kun: JSON-data, normative spesifikasjoner (Markdown), og fixtures (input + expected).

Regelen finnes fordi kjernen skal være fork-and-own: en konsument på en runtime vi ikke har tenkt på skal kunne vendore dette uten å arve et eneste teknologivalg. Mønsteret er kopiert fra søsterrepoet portfolio-optimiser-commons (samme harde charter: «nothing here may import/depend on a framework»).

Stack

Ingen. Data + prosa. Filformater: JSON (data + schema), Markdown (spec), rå tekst (conformance-input).

Konvensjoner

Data (JSON)

  • Hver JSON-fil har et topnivå "version"-felt (semver-streng). Uten unntak.
  • Hver JSON-fil har et topnivå "$comment" eller "description" som sier hva filen er og hvor dataene kom fra (provenance).
  • 2 mellomrom indentering, LF, avsluttende newline. UTF-8 uten BOM.
  • Kodepunkter skrives som "U+200B"-strenger (lesbare i review), aldri som rå usynlige tegn i JSON-kilden — bortsett fra i conformance/*/input.txt, som per definisjon inneholder de faktiske tegnene.
  • Nøkler er stabile identifikatorer. Å endre en nøkkel er en breaking change — konsumenter matcher på dem.

Spec (Markdown)

  • Hver normativ spec har en **Status: normative**-markør øverst.
  • RFC 2119-språk (MUST / MUST NOT / SHOULD / MAY) i store bokstaver, brukt bevisst.
  • Informative dokumenter (docs/) har **Status: informative** og er aldri ground truth.

Conformance

  • Én katalog per case: conformance/<case-id>/input.txt + conformance/<case-id>/expected.json.
  • <case-id> er stabil og beskrivende. Å endre en case-id er en breaking change.
  • expected.json er ground truth. Er en runtime uenig med expected.json, er runtimen feil — med mindre fixturen selv bevises feil, og da endres fixturen i eget commit med begrunnelse.
  • En case er ikke mintbar uten inngangspunkt for sitt scope. Korpuset pinner ikke lenger ett inngangspunkt per runtime for alt — manifest.jsonentry_points_by_scope bærer inngangspunkt, findings-accessor og fixture-presentasjon per scope per runtime. En runtime hvis flate er sti-basert kan ikke måle en løs input.txt, og en fixture den får som løs tekst måler ingenting samtidig som den ser ut som en pass. Nytt scope ⇒ fyll ut alle tre FØR første case.
  • Generatoren verifiserer aldri seg selv. En mint krever en separat sjekker som leser fixturene tilbake fra disk og utleder alt på nytt (digest, id fra case-id, scope, exact-within-scope). Ligger i scratchpad, aldri i repoet.
  • En innsnevring måles mot HELE korpuset, ikke mot casene den handler om. Endrer en oppdatering hva en runtime slutter å rapportere, kan den tømme et observed_out_of_scope hvor som helst — også på caser scopet til en helt annen tabell. Det feltet er evidens (spec §5), så ingen testsuite noe sted vokter det: en foreldet oppføring består hver kjøring for alltid. Kjør hver committet case gjennom sitt eget scopes inngangspunkt og sammenlign mot BEGGE stedene fixturen fører en runtime-label — findings (mappet via tabellens aliases) og observed_out_of_scope. Målt 2026-08-13: seks «åpenbare» caser flyttet seg ikke, én lexicon-case gjorde det.
  • En foreldet residue-oppføring skrives ikke om — den pinnes. observed_out_of_scope er sann ved commiten measurement pinner. Retter du én av 83, står 82 målinger ved én commit og én ved en annen, under en header som navngir én. Før avviket i manifestet med dato og commit i stedet. Å re-pinne hele korpuset er en egen beslutning.

Id-rom: adoptert vs. navngitt

Standard er adopsjon verbatim fra en runtimes egne labels (leksikonets 83, active contents 6). Å NAVNGI en id her er unntaket og krever at begge runtimes er spurt først — carrier:* er den eneste så langt, og carriers.json bærer begrunnelsen.

To regler som ikke er utledbare fra dataene:

  • Et prefiks som bare betyr noe inne i én runtime kan ikke bære et DELT id-rom. Guarden korrigerte oss selv på at «prefiks == detector-feltet» gjelder seks carrier-labels og er ingen lov i deres runtime. Skriv aldri den generelle formen; skop påstanden til de konkrete id-ene.
  • Å publisere aliases.<runtime> er den irreversible handlingen, ikke å minte casen. Konsumentens testsuite utleder sitt registrerte tabellsett ved å gå gjennom HELE den vendorede fila og registrere tabellen om ÉN node bærer aliaset. Granulariteten er FILA. Ett alias tvinger tabellen inn i deres DECLARED_TABLES og forplikter dem på hver case scopet dit. Mangler alias-strengen: la slotten stå tom og si detnot-applicable som registrerer et manglende NAVN er ærlig; en gjettet alias-streng er det ikke.

Sikkerhetskritiske tabeller — aldri fra hukommelse

codepoints/carriers.json (inkl. homoglyph-map), signatures/secret-egress.json, signatures/malware-signatures.json og signatures/active-content.json er deteksjonsdata. Et gjettet kodepunkt eller et regex med feil escaping er en stille falsk negativ — en detektor som ser ut som den virker.

Disse filene endres KUN fra verifisert kildedata (dump fra konsument-repo, Unicode-standarden, publisert leverandør-doc). Aldri fra egen hukommelse, aldri «fylt ut for konsistens». Kan en oppføring ikke verifiseres: utelat den, eller marker den eksplisitt uverifisert i $comment.

Å adoptere én ny id fra en runtime som har flyttet seg

Standardtilfellet er additivt: en ny nøkkel, en ny id, ferdig. Det holder bare når produsenten av den nye id-en er uendret. Er den nye id-en et nytt utfall av en klassifiserer runtimen har skrevet om, må hele klassifisereren adopteres — en publisert id oppå den gamle prediktoren er en kontrakt som ser komplett ut og er feil, og en konsument som implementerer fra den divergerer fra dag én. Test: kan du skrive den nye id-ens gate uten å røre de andre grenene i samme funksjon? Kan du ikke, er scope hele funksjonen.

To ting som følger av det:

  • En datafil kan ha TO provenance-pins, og da skal begge stå. Én pin over en fil som er halvt gammel og halvt ny beskriver ingen av halvdelene. Skop re-pinnen til de blokkene den faktisk dekker, og si hvilke.
  • Mål drift felt for felt før du re-pinner, ikke etterpå. Importer modulen ved taggen og sammenlign hvert regex, hver severity, hver liste og hvert tallgulv — etter å ha strippet Pythons inline-flagg-rendering og anvendt filas egne deklarerte normaliseringer, ellers rapporterer du staveforskjeller som drift. Målt 2026-08-13 over 0.3.4 → 0.7.0: 24 felt holdt, ett hadde driftet, og det var ikke det oppgaven handlet om.

Og den sterkeste kontrollen når du publiserer en klassifiserer som data: bygg den opp igjen fra JSON-en alene — ingen import fra runtimen — og differensialtest mot runtimens funksjon over et probe-korpus som treffer hver gren. Består den, er fila bevist tilstrekkelig som spesifikasjon. Består den ikke, mangler fila noe prosa aldri ville avslørt.

Behaviour preservation (v0.1.0-invariant)

v0.1.0 er en ekstraksjon, ikke en revisjon. Data som er hentet ut av en konsument skal gi eksakt samme funn når konsumenten senere leser dem herfra. Ser du noe du mener er feil i seed-dataene: ikke fiks det her. Dokumentér avviket, send det til konsumenten via coord-send, og la beslutningen tas der dataene er testet.

Kommandoer

Repoet har ingen build og ingen test-runner (charter). Validering er ad hoc:

# Alle JSON-filer er velformet
find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -n1 python3 -m json.tool > /dev/null

# Hver JSON-fil har topnivå "version"
for f in $(find . -name '*.json' -not -path './.git/*' -not -path './conformance/*'); do
  python3 -c "import json,sys; d=json.load(open('$f')); sys.exit(0 if 'version' in d else 1)" \
    || echo "MANGLER version: $f"
done

# Hver spec har normativ-markør
grep -L 'Status: normative' spec/*.md

# Charter-guard: ingen kjørbar kode har sneket seg inn
find . -type f \( -name '*.mjs' -o -name '*.js' -o -name '*.ts' -o -name '*.py' -o -name '*.sh' \) \
  -not -path './.git/*' | grep . && echo 'CHARTER-BRUDD: kjørbar kode i commons'

Arbeidsflyt

  • Versjonering: semver på repo-nivå (tag vX.Y.Z). Hver JSON-fils "version" er filens egen semver og bumpes når den filen endres — de er ikke låst til repo-taggen. Nytt datafelt eller ny oppføring = minor. Endret/fjernet nøkkel, case-id eller disposisjon = major (konsumenter bryter).
  • Versjonssync før commit: endrer du en JSON-fil, bump dens "version"; endrer du repoets kontrakt, bump repo-taggen + CHANGELOG.
  • Konsumenter varsles via coord-send, ikke via antakelse. Et repo som vendorer denne kjernen får ikke vite at kontrakten endret seg med mindre du sier det.
  • Aldri jobb i konsument-repoene fra en økt her. Vendoring, oppgradering og behaviour-verifisering skjer i konsumentens egen økt, med konsumentens tester.
  • Forgejo only (git.fromaitochitta.com). Aldri GitHub, aldri gh CLI.
  • STATE.md er LOCAL-ONLY (gitignored) — remote er en offentlig flate.

Communication patterns

Linking to local files

When pointing to local files in responses, always use markdown link syntax with a descriptive name:

  • Use [Human-friendly name](file:///absolute/path) — never bare file:///... URLs or autolinks <file://...>.
  • Always use absolute paths. Never ~/ or relative paths.
  • For multiple files, render as a bullet list of named markdown links.

Why: bare file:// URLs only render the first as clickable across multiple lines. Named markdown links make each entry independently clickable and look cleaner.

Example: