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
205 lines
11 KiB
Markdown
205 lines
11 KiB
Markdown
# 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.json` →
|
|
`entry_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 det** — `not-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:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
- [Brief](file:///Users/ktg/.../brief.html)
|
|
- [Research summary](file:///Users/ktg/.../research/summary.md)
|