1
0
Fork 0
llm-ingestion-pipeline-secu.../docs/PLAN.md
Kjell Tore Guttormsen 0eab4345bf
feat(output): decode hex-wrapped credentials at the persist gate
Gap 1 closed. The output gate finds runs of 32+ hex digits
(calibration.HEX_WRAP_MIN_LEN), decodes both byte alignments, keeps text
that is at least 80% printable (the base64 bar, now one shared helper in
entropy) and runs the credential patterns on it. A hex-wrapped AWS key
surfaces as decoded:egress:aws-access-key-id with "in hex run @ N" as
evidence; the value is never in it. Credentials only: a hex-wrapped
injection is not decoded.

Measured before and after on the same trees: all 3583 clean documents
(three FP populations and the 78 pinned OKF concepts) are identical line
for line under both presets (disposition, labels, severity, count,
offset); OKF import stays 47/31. They and 569 READMEs hold 2403 hex runs
of 32+ digits, none of which decodes to text. scan_output CPU 122.9 s
before, 123.4 s after; one solid hex run at the 1M cap 4.2 s -> 5.8 s.

14 new tests (10 red for the right reason first), 6 of 6 mutations red.
Matrix 146/146 + 5/5 gaps, suite 1274 passed, ReDoS sweep 169 patterns.
2026-11-01 17:06:44 +01:00

1038 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PLAN — `llm-ingestion-guard`
## Retning (godkjent av operatøren 01.11.2026)
> «llm-ingestion-guard holder seg på nyeste utgave av hver trusselkatalog over tid,
> ikke bare én gang. Hvert dokumentert hull som kan lukkes uten nye falske alarmer
> på rene dokumenter, blir lukket, og resten står med begrunnelse.»
Økta foreslo formuleringen, og operatøren svarte 01.11 «Ja til din anbefaling».
Den er godkjent ordrett. Bakgrunnen var at retningen fra 28.10 var nådd, men to
ting sto igjen. Katalogkravet var bare sjekket én gang (31.10), og de sju
dokumenterte hullene var registrert uten at noen hadde prøvd å lukke dem. Samme
svar avgjorde at de 16 juli-commitene med øktlenker blir stående.
Lest slik (antakelser, ikke operatørens ord):
- «Over tid» betyr en vakt som kjører av seg selv hver uke, pluss en regel som
stopper en release med en katalogsjekk eldre enn 30 dager. Vakten gjenbrukes fra
llm-security, slik retningen fra 28.10 ønsket («helst gjenbrukt»).
- «Dokumentert hull» betyr de sju i LIMITATIONS § «The seven documented gaps», som
dekningsmatrisen holder. Resten av LIMITATIONS er innrømmelser og målte rester,
ikke hull, og telles ikke.
- «Begrunnelse» betyr en målt begrunnelse: regelen som ble prøvd, og hvor mange
falske alarmer den ga, eller hvorfor ingen regel av denne typen kan se klassen.
- Ikke tatt med (operatøren valgte bort «bare rydding»): blokk-skalaren `a #b`,
støyen i ReDoS-sveipet og repo-standard-sjekken. De står under § V.5.
**Dekket i:** katalogene over tid → § V.2 rad 1, økt 2 (§ V.7) · hullene → rad 2, økt 1
(måling) og økt 3 og utover (lukking) · ferdig → siste økt.
---
# Vedlikehold 2026-11 (v1.8.0 →)
## V.1 Målt 01.11.2026 på v1.8.0 (`067e7ae`), før noe er bygd
- **Katalogene:** 16 oppføringer i `docs/threat-2026/catalogs.json`. Alle 16 er
sjekket for hånd 31.10. **0 av 16** sjekkes av en automatisk vakt: llm-security
sin utgavevakt (`scripts/edition-watch.mjs`, en ukentlig launchd-jobb hver mandag)
har 58 kilder og 0 av dem er fra dette repoet. Ingen test stopper en release med en
gammel sjekkdato.
- **Hullene:** 7, holdt av `tests/test_coverage_matrix.py::test_documented_gap_still_holds`
(en lukket sak gjør testen rød). Ingen av de sju har en målt begrunnelse. Det som
står, er «by design» eller «utenfor det token-analyse kan se». Første vurdering
(ikke målt, bare til å ordne økt 1): 1 (hex-innpakket hemmelighet) og 4 (bare første
treff per mønster) ser byggbare ut. 5 og 6 (ren beaconing, kort ugjennomsiktig
URL-ledd) er kostbare i falske alarmer. 2, 3 og 7 (falske fakta, alene-HIGH i
betrodd prosa, oppgave uten markør) er trolig ikke lukkbare med denne typen regel.
## V.2 Ferdig-kriteriet
| # | Måling | Nevner | Målt 01.11 (v1.8.0) | Ferdig |
|---|---|---|---|---|
| 1 | Kataloger fulgt over tid | de **16** i `catalogs.json`; kan utvides, krymper aldri uten operatørens ja | **0 av 16** under automatisk vakt; ingen release-regel | Hver av de 16 sjekkes av vakten, ELLER (hvis ingen maskin kan svare for den) står den som manuell med begrunnelse. Vakten har kjørt de siste 7 dagene og melder en kjent utdatert kopi (katalogene slik v1.4.1 brukte dem) som bak. En test stopper en release der en manuell sjekk er eldre enn 30 dager før releasedatoen |
| 2 | Dokumenterte hull | de **7** i LIMITATIONS, frosset i økt 1 (`docs/threat-2026/gaps-frozen.tsv`, sha256 `b1a6d5be…9d22a`) | **0 lukket, 0 med målt begrunnelse** | Hvert hull er lukket (saken fanges, og testen er flyttet fra hull til fanget), eller det står med målt begrunnelse i LIMITATIONS |
**Vaktregel, ikke mål (uendret fra § T.2):** ingen nye falske alarmer på de tre
FP-korpusene og det pinnede OKF-korpuset (6/53). Testsuiten, dekningsmatrisen,
ReDoS-sveipet og census er grønne på hver release. En regel som lukker et hull
men gir én ny alarm på rene dokumenter, lukker ikke hullet. Den blir en målt
begrunnelse.
**Ikke framme hvis én:** en katalog er verken fulgt av vakten eller begrunnet som
manuell · vakten har ikke kjørt de siste 7 dagene · et hull står uten målt
begrunnelse. Går to økter på rad uten at et av tallene flytter seg, legges det
fram for operatøren.
## V.3 Øktene
**Økt 1: måling og frysing.** Rad 1: avgjør for hver av de 16 hvilken probe-klasse i
vakten som kan svare, eller hvorfor ingen kan. Rad 2: frys de sju (fil + sha256),
skriv den billigste regelen for hvert hull og mål falske alarmer på korpusene FØR
noe bygges. Resultatet ordner rekkefølgen for økt 3 og utover. Ingen release.
**Økt 2: vakten.** Kildelista for de 16 i vaktens format i dette repoet. Kjent
positiv: vakten melder en frosset v1.4.1-kopi som bak. Ukentlig kjøring avtales med
llm-security (det er deres jobb og deres skript, så det avtales med dem og
endres ikke i deres repo herfra). Release-regelen blir en test. Release 1.9.0
hvis noe utgitt endres, ellers bare commit.
**Økt 3 og utover: ett hull per økt,** i den rekkefølgen økt 1 målte. Hver lukking er
en minor-release med beskjed til brukerne (ny deteksjon = atferdsendring). Et hull
som ikke kan lukkes uten falske alarmer får sin målte begrunnelse samme økt.
**Siste økt: sluttmåling.** Rad 1 og 2 telles på siste release med de frosne
nevnerne, og vakten må ha kjørt de siste 7 dagene.
## V.4 Hva som er avgjort
- Juli-commitene med øktlenker blir stående (operatøren 01.11).
- Et hull er lukket bare når regelen fanger klassen, ikke bare testsaken: hull 7 med
2 av 5 omformuleringer får målt begrunnelse (operatøren 01.11, § V.6 rad 2).
- Vakten gjenbrukes fra llm-security og skrives ikke på nytt i Python. Den er
vedlikeholdsverktøy, ikke en del av pakken, så stdlib-regelen for kjernen gjelder
ikke.
## V.5 Utenfor retningen
- Blokk-skalar `a #b` gir `'a #b'` der YAML gir `'a'` (gammelt avvik).
- ReDoS-sveipet gir 1–10 tilfeldige tidsflagg per kjøring (exit 0).
- repo-standard er ikke kjørt på den offentlige flaten.
- De fire angrepene utenfor (X01–X04) står utenfor med grunn, se § T.3. De er ikke hull.
## V.6 Økt 1, målt 01.11.2026 (ingen release)
### Rad 1: hvilken probe-klasse som kan svare for hver av de 16
Målt ved å kjøre llm-security sin vakt (`scripts/edition-watch.mjs` ved `45d3459`)
mot en prøveliste i scratch, med `--used` og en egen last-run-fil, så deres
stempel ikke ble rørt. Ingen fil i llm-security er endret.
| Oppføring | Klasse | Hva proben leser | Svar 01.11 |
|---|---|---|---|
| owasp-llm-top10 | A | genai.owasp.org sin ressursliste, årstallet i sluggen | ok, 2026 |
| owasp-agentic-top10 | B | samme liste, agentic-sluggene | ok, 2026 |
| mitre-atlas | F | GitHub `releases/latest` for atlas-data | ok, v2026.10 |
| owasp-rag-cheat-sheet | L (bare overvåking) | GitHubs commit-API for fila, `0.sha` | ok, `510ea484` uendret |
| cve-2025-32711 | L (bare overvåking) | CVE.org-posten i cvelistV5 (raw.githubusercontent.com), `cveMetadata.dateUpdated` | ok, 2026-02-26 |
| de 7 arXiv-artiklene | O | arXiv-API-et, én oppføring per artikkel | ok, 7 av 7 |
| unicode | ingen | unicode.org står ikke på vaktens vertsliste, og UCD-lesmeg er tekst, ikke JSON. unicodetools på GitHub har final-17.0 som siste utgivelse (18.0 er gjeldende), og taggene har ikke versjonsform | manuell |
| rfc-3986 | ingen | rfc-editor.org står ikke på vertslista. JSON-en har `obsoleted_by: []`, så en L-probe ville virke med verten | manuell |
| llm-security-commons | N | `git ls-remote` mot open/llm-security-commons | `auth-required`: repoet er privat, og proben kjører uten legitimasjon med vilje |
| willison-dual-llm | S | ingenting (klasse S gjør ingen forespørsel) | ett datert innlegg uten utgaver |
**12 av 16** kan svares av en klasse som finnes i dag, og alle 12 svarte `ok`.
**4 av 16** har ingen maskin som svarer nå: unicode og rfc-3986 trenger en vert på
llm-securitys liste (unicode også en tekstparser), commons blir maskinlesbar først
når repoet er offentlig, og Willison-innlegget har ingen utgave å følge.
- **Positiv kontroll:** samme liste med det v1.4.1 sto på der det kan uttrykkes
(OWASP LLM 2025, ATLAS v2026.09, 2509.14285v3) og en nullet RAG-sha. Vakten
meldte 3 som bak og RAG-arket under overvåking: 4 av 4.
- **arXiv må stå én per oppføring:** sju id-er i én oppføring gir `parse-failed`.
Den sammenslåtte verdien blir 90 tegn, og vaktens grense er 80
(`VERSION_RE`).
- **CVE-proben leser CNA-posten, ikke NVD.** Registeret siterer MSRC-revisjonen og
NVDs endringsdato. NVD-API-et og msrc.microsoft.com står ikke på vertslista, og
OSV har ingen post for denne CVE-en (svarte «not found» 01.11).
- **Klasse L melder en endring som `watch`, aldri som `behind`.** Det passer for et
levende dokument og en CVE-post, men release-regelen i økt 2 må lese `watch` også.
### Rad 2: de sju frosset, og den billigste regelen per hull målt før noe bygges
**Frosset:** `docs/threat-2026/gaps-frozen.tsv`, sha256
`b1a6d5be5fbfc81263e55135abf11bc6fe349eac82db0d1e84f9e44949b9d22a`. Den holdes av
`tests/test_gap_register.py`. Et hull som lukkes, føres i testens `CLOSED` i samme
commit som saken flyttes fra hull til fanget.
**Målt med `docs/gap-sweep.py`** (nytt, gjenkjørbart; korpusrøttene er argumenter).
Grunnlinjen er gjengitt eksakt med `docs/fp-sweep.py` før målingen: 106/198 ·
196/2918 · 108/389. Vaktpopulasjonene er de tre FP-korpusene og OKF-korpuset hentet
ved pinnen `3fcbb9f` (78 dokumenter, 0 ikke-WARN), til sammen **3 583 dokumenter**,
summert bare her for å telle overflaten. I tillegg en README-populasjon på **569**
pakke-README-er, bygd på nytt 01.11 fra `node_modules` og `dist-info/METADATA` under
repo-mappa og deduplisert på sha256. Den er ikke den samme som 577-populasjonen fra
31.10, og den trengs fordi vaktpopulasjonene har **0 eksterne bilder**. Alle åtte
regler fyrer på sitt hulls egen matrisetekst (positiv kontroll, låst i
`tests/test_docs_measurement_scripts.py`).
«Nye» betyr dokumenter som gaten regelen endrer slipper gjennom i dag (WARN), og som
regelen ville stoppet.
| # | Hull | Billigste regel | Nye alarmer, 3 583 dok. | README-er (569) | Utfall |
|---|---|---|---|---|---|
| 1 | Heks-innpakket hemmelighet | Dekod heks-løp på minst 32 tegn som gir skrivbar ASCII, og kjør hemmelighetsmønstrene på klarteksten | **0**. Ingen dokumenter har engang et slikt løp | 0 (0 har løpet) | **lukkbar** |
| 2 | Falske fakta | En påstand av typen «er laget av» eller «ble fullført i» | 0 | 3 fyrer | **ikke lukkbar med en tokenregel.** Minimalpar: regelen fyrer like mye på den sanne tvillingen («laget av jern og nikkel», «fullført i 1889»), og guarden gir de samme funnene (ingen) på sann og usann setning |
| 3 | Alene-HIGH i betrodd prosa | HIGH gir ikke WARN under betrodd forhåndsinnstilling heller | **48** på den betrodde gaten (6 + 11 + 31 + 0). Opplastingsgaten endres ikke | 67 | **målt begrunnelse: 48** |
| 4 | Dedup (`count=1`) | Tell hvert treff (`count` = antall) | **0** fordi `count` aldri når `decide()`. 53 dokumenter ville fått `count` > 1 (23 + 23 + 7 + 0) | 14 med `count` > 1 | **lukkbar** |
| 5 | Ren beaconing | Ethvert eksternt bilde graderes over LOW | 0, men målingen er **blind** fordi 0 av 3 583 har et eksternt bilde | **23 nye** av 211 som passerer i dag (52 fyrer) | **målt begrunnelse: 23** |
| 6 | Kort ugjennomsiktig URL-ledd | Et URL-ledd på 8–23 tegn som base64-dekoder til minst 6 skrivbare byte | 0. Blind på bilder, men 0 av 7 382 URL-er fyrer | 1 dokument fyrer, og det er allerede ikke-WARN, så **0 nye**. 4 av 14 943 URL-er | **lukkbar, men regelen må strammes** |
| 7 | Oppgave uten markør (A12) | 7a: «samle fra private steder». 7b: 7a pluss «legg det inn i dette dokumentet» i samme avsnitt | 7a: **6** (1 + 4 + 1). 7b: **0** | 0 og 0 | 7b lukker saken uten alarmer, men treffer bare **2 av 5** omformuleringer av samme forespørsel (7a også 2 av 5) |
Funn som styrer byggingen:
- **Hull 6:** to av de fire URL-treffene er ekte base64 i godartede adresser:
Patreon-bilder med størrelsen i stien (`{"w":200}`) og GitHubs `package_id`. Det
tredje er ordet «Pygments», som dekoder til seks skrivbare tegn. Regelen må kreve
mer av klarteksten enn «skrivbar» og måles på nytt før den bygges.
- **Hull 3 og 5 telles på gaten regelen endrer, og bare på en populasjon som har
klassen.** Det er en antakelse, ikke operatørens ord. Hull 3 gir 0 på
opplastingsgaten bare fordi regelen ikke rører den. Hull 5 gir 0 på
vaktpopulasjonene bare fordi de ikke har bilder, og en måling uten positiv kontroll
teller ikke.
- **Hull 7 telles ikke som lukket** (operatøren 01.11: «OK til din anbefaling»). En
regel som fanger testsaken uten alarmer, men bommer på 3 av 5 omformuleringer,
kjenner én setning, ikke klassen. Hullet får målt begrunnelse.
**Rekkefølgen for økt 3 og utover:** 4 (dedup, billigst og 0 per konstruksjon) →
1 (heks, 0 overflate) → 6 (kort ledd, strammes og måles igjen). De fire begrunnelsene
(2, 3, 5 og 7) står klare med tallene over. De skrives inn i LIMITATIONS i økt 3,
så de kommer med i samme release som den første lukkingen.
---
## V.7 Økt 2, vakten (01.11.2026, ingen release)
**Kildelista** er `docs/threat-2026/watch-sources.json`: de 16 i registeret, i vaktens
egen form (en liste med ett `probe`-objekt per oppføring). Den er generert fra
`catalogs.json`, og `tests/test_release_rule.py` holder dem like: samme id-er, samme
`cited`, `latest`, `source` og `checked`. 12 oppføringer har en probe som gjør en
forespørsel (A, B, F, L×2, O×7, som målt i § V.6). De 4 manuelle (unicode, rfc-3986,
commons, Willison) står med klasse S. Det er vaktens egen form for «ingen maskin
kan svare» (llm-security bruker den på 6 av sine 58). Hver har en begrunnelse som
starter med `manual:`, og sjekkdatoen er registerets `checked`.
**Kjørt live 01.11** med vakten ved `45d3459`, med `--used` og en egen last-run-fil
i scratch. llm-securitys stempel hadde samme sha256 før og etter.
| Liste | behind | watch | notVerified | current | Prober ok | exit |
|---|---|---|---|---|---|---|
| `watch-sources.json` | 0 | 0 | 0 | 16 | 12 | 0 |
| `watch-sources-v1.4.1.json` (frosset) | 1: owasp-llm-top10 (2025 → 2026) | 1: owasp-rag-cheat-sheet (41f67299 → 510ea484) | 0 | 12 | 10 | 1 |
Utdataene er committet som `watch-last.json` og `watch-v1.4.1-run.json`.
**Den frosne v1.4.1-kopien** inneholder bare det v1.4.1 (tagget 2026-09-23) siterte,
og hver oppføring har utgaven utgiveren serverte den dagen. OWASP Agentic og ATLAS er
utelatt fordi v1.4.1 ikke siterte dem. RAG-arket sto på `41f67299` (2026-05-06), lest
fra GitHubs commit-API med `until=2026-09-23`. Uversjonerte arXiv-lenker gir de samme
versjonene som nå, fordi alle ble publisert før tag-dagen. Fila er låst med sha256 i
testen.
**Funn:** vakten melder Unicode 17.0 som `current`, fordi klasse S ikke spør. Det er
bare 30-dagersregelen som fanger den. Positiv kontroll med alle seks problemene: 4
manuelle sjekker fra 23.09, 1 behind og 1 watch.
**Release-regelen** (`tests/test_release_rule.py`) leser releasedatoen fra den
nyeste CHANGELOG-overskriften. Den binder derfor på release-commiten og ikke på en
vilkårlig dag mellom to releaser. En test holder overskriften lik versjonen i
`pyproject.toml`. Regelen stopper når:
- en manuell sjekk er mer enn 30 dager eldre enn releasedatoen;
- vaktens siste kjøring er mer enn 30 dager eldre (den er sjekkdatoen for de 12);
- utdataene ikke er en kjøring av denne lista;
- det finnes en rad i `behind`, `notVerified` eller `watch`. En `watch`-rad teller
aldri med i vaktens exit-kode, så regelen leser den for seg. Den ryddes av en
sjekk for hånd som oppdaterer `known_value`, og deretter en ny kjøring.
Hver klausul er mutert bort én om gangen, og hver mutasjon gjorde en test rød (5 av 5).
**Før en release:** kjør
`LLM_SECURITY_EDITION_WATCH_LAST_RUN=<scratch>/lr.json node <llm-security>/scripts/edition-watch.mjs --used docs/threat-2026/watch-sources.json > docs/threat-2026/watch-last.json`
og commit utdataene. Uten den miljøvariabelen stempler kjøringen llm-securitys egen
last-run-fil.
**Ukentlig kjøring:** foreslått til llm-security via coord 01.11. Det er deres jobb
(`run.sh` i deres tilstandsmappe, launchd mandager 09:15), så den endres ikke herfra.
Rad 1 er ikke framme før jobben har kjørt lista vår de siste 7 dagene.
**Sett, ikke endret:** commons-proben (N) svarer `ok` under llm-securitys jobb, som
setter en ssh-remote. Deres `last.json` fra 26.10 har 0 i `notVerified`. Commons blir
likevel stående manuell her. llm-securitys egen liste følger de samme commons-taggene
hver uke. Under en annen kjøring uten ssh-remote ville proben her svare
`auth-required`.
Ingen release, fordi ingenting i den utgitte pakken er endret.
## V.8 Økt 3, hull 4 lukket og fire begrunnelser (01.11.2026, release 1.9.0)
**Hull 4 (dedup):** `scan_lexicon` gir fortsatt ett funn per mønster-id med første
offset, men `count` er nå antall treff. Leksikonet leser flere varianter av teksten
(rå, dekodet, foldet, rot13), og samme treff vises i flere av dem. Derfor er `count`
det høyeste antallet i én enkelt variant, og variantene summeres ikke. Det er en nedre
grense: et rent treff pluss et rot13-skjult teller 1. For orddelingsraden telles bare
skjøtene raden melder. Matrisesaken står nå som fanget (`lexicon:count`), og
`tests/test_gap_register.py` har `CLOSED = {"dedup": "count"}`.
- **TDD:** fem tester i `tests/test_lexicon.py`. Tre var røde av riktig grunn
(`count` 1, ventet 2), og to låser det som allerede holdt (ett treff gir 1, og
rot13 pluss rent treff gir 1). Mutasjonssjekk: med `count=1` igjen ble 5 tester røde.
- **Vaktregelen:** målt før og etter på samme trær. FP-korpusene er identiske
linje for linje på 106/198 · 196/2918 · 108/389. Alle 3 583 dokumenter (de tre
korpusene og de 78 OKF-konseptene ved pinnen) har samme disposisjon under begge
forhåndsinnstillingene, og samme etiketter og alvorlighetsgrader. OKF-importen står
på 47 fail-secure / 31 warn, uendret. **53** dokumenter får `count` > 1
(23 + 23 + 7 + 0), som målt i § V.6, og høyeste `count` er 26. 0 nye alarmer.
- **Ytelse:** `scan_lexicon` over de 3 505 dokumentene i de tre FP-korpusene tok
96,0 s før og 97,1 s etter, og de tregeste dokumentene er de samme.
- **Rad 2:** hull 4 er lukket. 2, 3, 5 og 7 står med målt begrunnelse i LIMITATIONS
(tallene fra § V.6). 1 og 6 er åpne, og de er neste.
**Vakten** er kjørt på nytt 01.11 før release-commiten: 16 current, 0 behind, 0 watch,
0 notVerified. `watch-last.json` er endret bare i `lastRun`.
**llm-security** har ikke svart på forespørselen om ukentlig kjøring. Meldingen
ligger fortsatt ubehandlet i innboksen deres. Rad 1 er ikke framme før jobben deres
har kjørt lista vår.
## V.9 Økt 4, hull 1 lukket (01.11.2026, release 1.10.0)
**Hull 1 (heks-innpakket hemmelighet):** utgangsporten finner løp på minst 32
heks-sifre (`calibration.HEX_WRAP_MIN_LEN`), dekoder dem og kjører
hemmelighetsmønstrene på klarteksten. Funnet heter `decoded:egress:*`, som for
base64, og beviset sier `in hex run @ <offset>`. Matrisesaken `hex-wrapped` står nå
som fanget og sjekker at funnet kom fra heks-løpet, ikke fra en base64-dekoding.
`tests/test_gap_register.py` har `CLOSED = {"dedup": "count", "hex-wrapped": "hex-wrapped"}`.
**To avvik fra regelen målt i § V.6**, begge for å stå imot en enkel omgåelse:
- Begge byte-justeringene dekodes. Med bare én ville ett løst heks-siffer foran
nøkkelen (eller et oddetall-løp) gjemt den.
- Kravet er minst 80 % skrivbare tegn, samme krav som base64-klarteksten møter (én
delt hjelper i `entropy`), ikke 100 %. Med 100 % ville én søppelbyte foran nøkkelen
gjemt den.
Begge målt som bygget (under), så § V.6-tallet er ikke gjenbrukt.
Bare hemmelighetsmønstrene kjøres på klarteksten. En heks-innpakket injeksjon dekodes
ikke (en base64-innpakket gjør det). Det står i LIMITATIONS.
- **TDD:** 14 nye tester i `tests/test_output.py` erstatter den gamle hull-testen. De ti
som krever et funn var røde av riktig grunn (ingen funn), de fire negative holdt
allerede. Kalibreringstesten (`HEX_WRAP_MIN_LEN` frosset og lest fra
`calibration`) ble skrevet før konstanten, men ikke kjørt rød da. Den er rød med
33. Netto +14 tester. Mutasjonssjekk: bare justering 0, gulv 34, gulv 30, 100 % skrivbart,
steget fjernet og bevis-suffikset fjernet gir hver minst én rød test (6 av 6).
Gulv 30 overlevde først, og gulvtesten fikk en sak som dekker den.
- **Vaktregelen:** målt før og etter på samme trær. Alle 3 583 dokumenter har samme
disposisjon under begge forhåndsinnstillingene og samme etiketter,
alvorlighetsgrader, `count` og offset (utdataene er identiske linje for linje).
OKF-importen står på 47 fail-secure / 31 warn, uendret. 0 nye alarmer.
- **Overflaten:** de 3 583 og de 569 README-ene har **2 403** heks-løp på minst 32
tegn i **2 351** dokumenter (5 + 2 311 + 4 + 0 + 31; sjekksummer og id-er). **0**
dekodes til tekst, så regelen får aldri noe å skanne. Positiv kontroll: matrisetekstens
løp dekodes og fyrer, og et løp med vanlig tekst dekodes uten å fyre.
- **Ytelse:** `scan_output` over de 3 583 tok 122,9 s CPU før og 123,4 s etter. Ved
taket på 1 000 000 tegn koster ett sammenhengende heks-løp 5,8 s mot 4,2 s før,
og 31-tegns løp (under gulvet) 4,5 s mot 4,2 s.
- **ReDoS:** sveipet teller 169 mønstre (nytt: `_HEX_RUN_RE`). Fire kjøringer ga 1, 0,
0 og 0 tidsflagg (den kjente støyen), og output-tabellen alene 0 av 14.
- **Rad 2:** hull 1 og 4 er lukket, og 2, 3, 5 og 7 står med målt begrunnelse. Hull 6
er åpent og neste: regelen må kreve mer av klarteksten og måles på nytt (§ V.6).
## Forrige retning (godkjent av operatøren 28.10.2026, nådd 31.10)
> «llm-ingestion-guard bygger på nyeste utgave av hver trusselkatalog den viser til,
> hver med dato for siste sjekk og kildelenke. Ingen funn eller tekst bruker et
> katalognummer med en eldre utgaves betydning. De kjente angrepene fra 2025–2026
> som kan nå en innhentings-pipeline, er testsaker. Hver av dem fanges eller står
> som et dokumentert hull.»
Operatørens ord: «Dette repoet bør også oppdateres [til de siste
trusseldefinisjonene], og det er sikkert lurt å hente inspirasjon fra det som gjøres
med LLM Security.» Hva som er lagt til, og hvorfor:
- «Siste trusseldefinisjoner» er gjort målbart som tre ting: kataloger, numre og
angrep. Det er målemetoden llm-security bruker, avgrenset til det som gjelder et
bibliotek.
- «Kan nå en innhentings-pipeline» avgrenser hvilke angrep som teller (§ T.3).
- Ikke tatt med: automatisk varsling om nye katalogutgaver (kan komme senere, helst
gjenbrukt) og kostnadsregelen for alltid-lastet tekst (gjelder bare en plugin).
Operatøren 28.10: «Ja til endringen. Lag en skikkelig plan, vi bruker så mange økter
som trengs for å gjøre dette skikkelig.» Det samme «ja» godkjente den anbefalte
merkingen: funn merkes med årstall (`LLM10:2026`, OWASPs egen skrivemåte), og
endringen gis ut som 1.5.0 med en tydelig merknad.
**Publisering (operatøren 28.10):** «Tanken er at når dette repoet er oppgradert så
skal vi flytte det ut til Forgejo open.» Repoet er privat under oppgraderingen
(målt 28.10: `open/`-adressen sender videre til en privat kopi). Det flyttes til
`open/` etter sluttmålingen, se § T.4 «Siste økt». Valget om å publisere testsakene
åpent gjelder fra da.
**Dekket i:** katalogene → § T.4 økt 4 (rad 1) · numrene → økt 3 (rad 2) ·
angrepene → økt 1 (fryser lista), 2 og 5–7 (rad 3) · ferdig → siste økt.
---
# Trusselbildet 2026 (v1.4.2 →)
Planen til retningen over. Den gamle v1-planen står uendret lenger ned under
«Implementation Plan — v1». Den er historikk og telles ikke i rad 2, på samme måte
som CHANGELOG. Planen oppdateres underveis og overskrives aldri.
## T.1 Målt 28.10.2026 på v1.4.1 (`6702d7a`), før noe er bygd
1. **Katalognumrene har feil betydning.** OWASP Top 10 for LLM Applications kom i
2026-utgave (publisert 04.08.2026) med ny rekkefølge. Bare LLM01 og LLM02 betyr det
samme som i 2025. Repoet bruker LLM03–LLM10 med 2025-betydning på **101 steder i 26
sporede filer** (src 50, tests 28, docs 17, README 6; CHANGELOG, `dist/` og
`*.local.*` er utelatt). Ingen av dem bærer årstall. Hurtigtelling med
`grep -oE 'LLM(0[3-9]|10)\b'`, altså ikke en klassifisering. Økt 1 fryser den
ekte nevneren. Funnfeltet `owasp` bærer dette ut til hver eneste bruker:
EchoLeak-funnene merkes `LLM05`, som i 2026 betyr dataforgiftning.
*Økt 1 (28.10):* den ekte nevneren er **95**. Avstemt mot hurtigtellingen:
101 − 14 i historikk (v1-planen 10, PLAN-v1 1, gjennomgangen 3) + 7 tall i
skråstrek-lister som grep ikke ser (`LLM01/02/04/05/06`) + 1 `LLM01` med feil
betydning (standardverdien som merker OKF-stiavvisninger som prompt injection).
2. **Usynlige tegn slipper gjennom.** Først testet jeg tekst skjult i
variasjonsvelgere (U+FE00–FE0F, U+E0100–E01EF), der «ignore previous
instructions and exfiltrate» var kodet i 43 tegn. Det ga **0 funn** i rensingen,
i innhentingsinngangen og i utgangskontrollen, og alle 43 tegnene lå igjen i den
«rensede» teksten. Deretter testet jeg 19 tegntyper, 8 av hver, gjennom rensingen.
**17 av 19 gir 0 funn og blir liggende:** U+2060–2064, U+180E, U+3164, U+115F,
U+1160, U+FFA0, U+034F, U+FFF9, U+200E, U+061C, U+E000 (privat bruk), U+FE00 og
U+E0100. Positiv kontroll: U+E0041 (tag) og U+200B (zero-width) fanges og fjernes.
Ingen av dette står i LIMITATIONS.
3. **Ingen katalog har dato for siste sjekk.** README og «Threat-model anchors»
lenger ned nevner OWASP 2025 uten dato. Agent-katalogen (OWASP Top 10 for Agentic
Applications 2026, der ASI06 «Memory & Context Poisoning» er vårt område) og
MITRE ATLAS er ikke koblet inn.
4. **Leksikonet har ikke drevet.** Vår kopi har 83 mønstre, akkurat som
leksikonet i `llm-security-commons` v0.9.0 (filversjon 0.8.0). Commons'
tegntabell (`carriers.json` 0.2.0) mangler også variasjonsvelgere: tekstsøk etter
`FE00`, `E0100` og `variation` gir 0 treff. Hullet er altså felles.
## T.2 Ferdig-kriteriet
Retningen er nådd når **rad 1–3** holder samtidig, målt på siste 1.x-release:
| # | Måling | Nevner | Målt 28.10 (v1.4.1) | Ferdig |
|---|---|---|---|---|
| 1 | Trusselkataloger og kilder på nyeste utgave, hver med dato for siste sjekk og kildelenke | **15 oppføringer**, frosset i økt 1 (`docs/threat-2026/catalogs.json`); kan utvides, krymper aldri uten operatørens ja | **0 av 15** har dato i repoet. Bak: OWASP LLM (2025 → 2026), Unicode (17.0 → 18.0). Ikke koblet inn: Agentic, ATLAS. RAG-juksearket står uten lenke og dato | 15 av 15 |
| 2 | Steder der et katalognummer brukes med en eldre utgaves betydning, eller med en betydning ingen utgave har gitt det | telling over sporede filer, med historikk utenfor (CHANGELOG, v1-planen, utgitte gjennomganger), med `docs/catalog-census.py` | **95** i 23 filer: 82 med gammel betydning, 13 med feil. Frosset ved `6702d7a`, listing-sha256 `da1aa0fa…6f` | 0, og en permanent test holder det der |
| 3 | Kjente 2025–2026-angrep som kan nå en innhentings-pipeline, som testsaker | **19 rader** innenfor (pluss 4 utenfor med grunn), frosset i økt 1 (`docs/threat-2026/attacks.json`) | **8 fanges**, 1 er et dokumentert hull (PoisonedRAG), **10 er røde** (12 av 31 ankomster) | hver sak fanges, eller står som hull i LIMITATIONS og dekningsmatrisen |
**Vaktregel, ikke mål:** ingen nye falske alarmer på korpusene vi allerede måler mot
(falsk-positiv-korpuset og det pinnede OKF-korpuset på 6/53), og testsuiten,
dekningsmatrisen og ReDoS-sveipet er grønne på hver release. En regel som strammer inn
og gir nye alarmer på rene dokumenter, skal måles og legges fram, ikke slippes stille.
**Ikke framme hvis én:** en katalog er bak eller mangler dato · ett gammelt
nummer-sted gjenstår · en angrepssak verken fanges eller er dokumentert.
Underveis: går to økter på rad uten at et av tallene flytter seg, legges det fram for
operatøren.
## T.3 Hva som teller som «kan nå en innhentings-pipeline»
Angrepet teller når nyttelasten kommer fram som **innhold** som en pipeline henter
eller mottar og skriver til et lager en modell eller agent senere leser. Det teller
også når nyttelasten rir på modellens transformerte output før den lagres. Det teller
ikke når angrepet krever kodekjøring på verten, tilgang til modellvektene eller en
kompromittert pakke (kjernen er stdlib-only). Grensetilfeller legges fram for
operatøren i økt 1 og avgjøres ikke stille.
**Frosset 28.10 (økt 1):** `docs/threat-2026/attacks.json`, med 19 rader innenfor
og 4 utenfor. Hver rad har primærkilde, dato og mekanisme, og hver ankomst er en
test i `tests/test_attacks_2026.py`. Operatøren avgjorde grensetilfellene 28.10:
- Angrep der nyttelasten kommer inn som innhold, men skaden krevde agentverktøy, er
**med**. Testen er nyttelasten slik den kommer inn: Comment and Control,
ShadowLeak, Comet og GitHub-issue-flyten.
- ToxicSkills er med, men **bare tekstdelen**.
- MemoryTrap er **ute**, fordi det krever kodekjøring via npm postinstall (Cisco
01.04.2026, verifisert). Unit 42s minneforgiftning via sammendrag er ASI06-raden.
- Seks tillegg er tatt inn: CopyPasta, Invitation Is All You Need, GeminiJack,
Unit 42 ×2 og Comet.
- To kilder som bare finnes fra 2024 er ute (SpAIware og ASCII-smugling mot
Copilot). PoisonedRAG står.
Rettelser fra verifiseringen: CamoLeak har ingen CVE. CopyPasta-nyttelasten ligger i
README, ikke i en lisensfil. GlassWorm er en utvidelse (pakke) og står utenfor, men
teknikken er rad A01.
Kandidatlista slik den sto før verifiseringen (historikk):
- Smugling i variasjonsvelgere og andre usynlige tegn. *Målt rød 28.10.*
- EchoLeak, CVE-2025-32711 (null-klikk-eksfiltrasjon via markdown i output).
*Dekket i dag.*
- ShadowLeak, CamoLeak, ForcedLeak, AgentFlayer: eksfiltrasjon eller injeksjon via
dokumenter, e-post eller kontakter som en agent leser.
- Forgiftede minnefiler (MemoryTrap-klassen): innhold skrevet til agentens minne,
som er OWASP ASI06.
- Skill-innhold med skjulte instruksjoner (ToxicSkills-klassen).
- Injeksjon via issue- og PR-tekst som en agent henter (Comment and Control).
- Faktaforgiftning (PoisonedRAG). *Allerede dokumentert som hull. Saken skal bekrefte
at hullet står.*
- Skjult tekst i opplastede dokumenter (HTML/CSS-skjult, hvit tekst).
**Testsakene publiseres åpent** sammen med koden, slik som dagens korpus (operatøren
28.10). Grunnen er at brukerne selv skal kunne
etterprøve hva som fanges og hva som er hull. Nyttelastene er inerte: bare kjente
teknikker, `*.test`-domener og ingen fungerende kjede mot et navngitt produkt.
## T.4 Øktene
Hver økt er én leveranse, kan gis ut for seg og følger TDD med rødt før grønt.
Nummereringen er rekkefølgen, og hver økt starter fra STATE.md.
**Økt 1: måling, rød før noe bygges. Ingen produktkode.**
- Rad 1: Lag katalogregisteret som data i repoet. For hver kilde repoet viser til
(OWASP LLM Top 10, OWASP Agentic Top 10, MITRE ATLAS, OWASP RAG Security Cheat Sheet,
Unicode-versjonen som tegntabellene måles mot, og de andre i «Threat-model
anchors»): utgaven som brukes, nyeste utgave, sjekkdato og kildelenke. Hver utgave
verifiseres mot utgiveren. En kopi som er verifisert et annet sted, er et premiss
og ikke et måleresultat.
- Rad 2: Lag et telleskript som klassifiserer hvert sted som *gammel betydning* (kan
mappes mekanisk, § T.5) eller *feil betydning* (krever en vurdering; for eksempel
bruker OKF-avvisninger av YAML-former i dag `LLM10`). Frys nevneren med sha256.
- Rad 3: Verifiser kandidatene i § T.3, legg fram grensetilfellene og frys lista.
Skriv hver sak som test, markert som forventet rød der den ikke fanges i dag.
- Tegnnevneren til økt 2: Unicodes egen `Default_Ignorable_Code_Point`-mengde
(DerivedCoreProperties.txt, versjonen pinnet i registeret) pluss privat bruk-
områdene. Det er en prinsipiell nevner i stedet for en håndplukket liste.
*Gjort 28.10:* `docs/threat-2026/codepoints.json` (Unicode 18.0.0, kildefilene
sha256-pinnet). Operatøren la til formattegnene (Cf), fordi U+FFF9 ble målt rødt,
men ikke er DICP. Nevneren er DICP 4174 ∪ Cf 170 ∪ privat bruk 137 468 =
**141 674** tegn. 32 Cf-tegn ligger utenfor DICP, blant dem arabiske talltegn
(U+0600–0605) som vises. Økt 2 må unnta dem på kontekst.
**Økt 2: usynlige tegn. Gis ut som 1.4.2 (deteksjonsatferd, ingen ny API).**
- Hvert tegn i nevneren fanges og fjernes, eller er unntatt med en navngitt grunn
på samme måte som ZWJ i dag: det dømmes på **kontekst**, ikke identitet. Kjente
legitime bruk som må overleve: VS16 og VS15 etter emoji, ideografiske varianter
(U+E0100–E01EF etter et CJK-tegn, én velger per tegn), ZWNJ i persisk og indiske
skrifter (allerede en dokumentert rest), og LRM/RLM/ALM i høyre-til-venstre-tekst.
En *rekke* av velgere er bærer uansett hva den står etter.
- Både innhentingssiden og utgangskontrollen, med samme predikat på begge sider
(som for ZWJ).
- Vaktregelen er måling på falsk-positiv-korpuset og OKF-korpuset før og etter.
- Etterpå sendes målingen og kontekstregelen til `llm-security-commons` som innspill
til tegntabellen. Commons eier sin tabell, og vi skriver aldri dit.
*Gjort 28.10 (1.4.2):* alle 141 674 tegn i nevneren fanges utenfor kontekst, på
begge sider med samme predikat (testet tegn for tegn). A01 og A02 er grønne, så
**rad 3 står på 10 fanget, 1 hull og 8 røde rader (8 av 31 ankomster, var 12)**.
Operatøren avgjorde de tre åpne unntakene etter anbefaling: privat bruk **fanges
helt**, arabiske talltegn **unntas foran et siffer**, og planen om **én ideografisk
velger per CJK-tegn står**. Hva hvert unntak lar stå åpent, står i LIMITATIONS.
Målt før bygging: av 3 505 dokumenter i falsk-positiv-populasjonene og 78 i
OKF-korpuset bærer bare 348 VS16 etter emoji og 1 ZWJ mellom emoji noe fra
nevneren. ZWNJ-regelen er målt mot 9 Wikipedia-artikler (fa, hi, ml, ur, he, ar).
Der hadde 4 666 av 4 784 skrifttegn på begge sider, og resten sto ved markup eller
virama. Derfor er regelen «én side», og artikler med funn gikk fra 6/9 til 2/9.
Ett tillegg til planen: leksikonraden for orddeling dømmer nå ZWNJ med det samme
predikatet. Ellers ville persisk fortsatt fått MEDIUM på hvert ord, selv med tegnet
i behold. Selve regex-en er uendret og byte-lik med commons. Vaktregelen er målt
før og etter: OKF er identisk (6/53, import 47/31), og falsk-positiv-tallene står i
CHANGELOG for 1.4.2.
**Økt 3: katalognumre med årstall (rad 2 → 0). Gis ut som 1.5.0.**
- Alle funn bærer `LLMxx:2026`. Mapping fra 2025-betydning, verifisert i økt 1:
LLM04 → LLM05:2026 · LLM05 → LLM10:2026 · LLM06 → LLM03:2026 ·
LLM08 → LLM09:2026 · LLM09 → LLM07:2026 · LLM10 → LLM06:2026 ·
LLM01 og LLM02 er uendret i betydning og får årstall.
- Teknisk valg (kan avvises med grunn): leksikondataene holdes byte-like med
commons, og årstallet settes på ett sted der funnet lages. Når commons publiserer
numre med årstall, tas de inn.
- Stedene med *feil betydning* rettes etter vurdering, hver med begrunnelse i commit.
- Den permanente testen feiler på et katalognummer uten årstall, eller med et
årstall som ikke er nyeste, i sporede filer utenom historikk.
- CHANGELOG-merknaden sier rett ut at en bruker som filtrerer på `"LLM05"`, mister
treff. Konsumentene varsles.
*Gjort 31.10 (1.5.0):* **rad 2 står på 0** (var 95). Den frosne nevneren er uendret
ved `6702d7a` (95, `da1aa0fa…6f`), og tellingen over arbeidstreet gir 0 gammel og 0
feil. Den permanente testen er `tests/test_catalog_numbers.py`. Den feiler på
nummer med gammel eller feil betydning, på nummer uten årstall (unntatt
leksikondataene fra commons) og på funn fra matrisen som mangler nyeste årstall.
Hva som er «nyeste», leses fra katalogregisteret. Produsentene navngir risikoen,
ikke nummeret: én tabell (`_owasp.py`) eier numrene. Leksikonet er byte-likt med
commons og får årstallet når det lastes. Et nummer derfra som har flyttet seg
mellom utgavene, avvises i stedet for å gjettes.
De 13 stedene med feil betydning er vurdert slik:
- Faktaforgiftning (PoisonedRAG) er **LLM05:2026** (2 steder: matriseraden og
testen som følger den). Det er 2026-tekstens eget eksempel 6, «RAG Knowledge
Base Poisoning».
- De andre 11 stedene er fail-secure (README og matrisen), parserflate (4),
URL-validering av mottatt metadata (4) og standardverdien som merket
OKF-stiavvisninger som prompt injection (1 sted, 5 rader). De får **ikke noe
nummer**, fordi ingen utgave beskriver dem. Et tvilsomt nummer ville vært den
samme feilen på nytt. I matrisen er det 14 rader uten nummer, og hver av dem er
festet i testen.
Selvsikkerhet beholder **LLM06:2026**. 2026-teksten er lest hos utgiveren: eksempel
1 («input flood … exploiting processing inefficiencies … render the system
unresponsive») og eksempel 3 (applikasjonssidig re-chunking) dekker inputtak og
ReDoS. README-ankeret «LLM09» (Misinformation) er tatt ut. Det var
faktaforgiftnings-raden, og grounding-kroken er for den.
**Økt 4: katalogregisteret i bruk (rad 1). Ren dokumentasjon, gis ut med neste
release.**
- README-delen «What it protects against» skrives om på 2026-utgaven og kobles til
ASI06 og relevante ATLAS-teknikker. Hver kobling verifiseres mot katalogen, og
alt som ikke er verifisert, utelates.
- En test feiler når registeret mangler dato eller lenke. Den feiler ikke på alder:
automatisk varsling er utenfor retningen.
- *Gjort 31.10:* **rad 1: 16 av 16.** Alle 15 er sjekket på nytt hos utgiveren.
Bare ATLAS hadde flyttet seg: v2026.10 kom 28.10 kl. 22:55 UTC, etter økt 1.
`AML.T0057` finnes ikke lenger, så den er ikke koblet inn. CleanBase sto i BRIEF
uten å være i registeret, og er lagt til (15 → 16). README kobler ASI06 og sju
ATLAS-teknikker, hver med det vakta *ikke* stopper. Målt: tekst gjemt med hvit
eller 1px skrift gir 0 funn, mens null skriftstørrelse, base64 og rot13 fanges.
Unicode-målingen for ZWJ er tatt på nytt på 18.0 (1614 sekvenser, 122/122).
«458» kom fra Pythons Unicode 16.0, og riktig tall for 18.0 er 406.
`tests/test_catalog_register.py` holder rad 1. Utenfor tellingen
(`candidates`): «AI Agent Traps» (SSRN svarer 403, ikke verifisert) og OKF-
spesifikasjonen (operatørens beslutning).
**Økt 5…N: angrepssakene (rad 3).** Lista fra økt 1 tas i bolker. Hver bolk blir
enten deteksjon (TDD, vaktregelen holder) eller en dokumentert rest i LIMITATIONS og
dekningsmatrisen (status `gap`, slik matrisen allerede gjør). Hver bolk er en minor-
eller patch-release. Antall økter avhenger av lista og fastsettes etter økt 1.
*Fastsatt av operatøren 28.10 («Ja til din anbefaling»): tre bolker, A01 og A02
tas i økt 2:*
- **Økt 5, skjult markup:** A04 (tekst i 1px og hvitt; `zero-font` krever en
størrelse som starter på 0) og A15 utgang (HTML-kommentar i modellens output gir 0
funn).
*Gjort 31.10:* A04 og A15 (begge ankomster) fanges, så rad 3 har nå **12 fanget,
1 hull og 6 røde** (A05, A08, A09, A12, A16, A19). `html-obfuscation:tiny-font`
(under 4px) og `html-obfuscation:invisible-colour` (samme farge som bakgrunnen i
samme attributt, eller gjennomsiktig) bor i `output.py`, fordi leksikonet er
byte-likt med commons. Hvit tekst alene er bevisst ikke et signal (e-postknapper).
Det er målt at ren prosa i hvitt gir 0 funn, og det er ført i LIMITATIONS.
`output:html-comment-present` (MEDIUM) teller bare kommentarer utenfor kode.
I referansekorpuset står 253 av 254 kommentarer i kode.
- **Økt 6, rekkefølge-bæring:** A05 utgang. Hvert bilde bærer ingen data, men
rekkefølgen gjør det. Det graderes LOW og gir WARN.
*Gjort 31.10 (1.7.0):* A05 fanges, så rad 3 har nå **13 fanget, 1 hull og 5
røde** (A08, A09, A12, A16, A19). `active:markdown-image-sequence` (MEDIUM) melder
minst 4 vanlige eksterne bilder på samme URL-mal der bare korte tokener (2 tegn
eller færre) eller sifre varierer, med unntak for sifre som teller oppover.
Bildene graderes fortsatt hver for seg; funnet står ved siden av. Målt før
bygging: de tre falsk-positiv-korpusene har ikke ett eksternt markdown-bilde, så
de kan ikke prøve regelen. Derfor er den også målt på 411 npm-README-er og 166
PyPI-beskrivelser (312 med eksterne bilder) og OKF-korpuset: 0 treff for alle
lengder fra 3 til 8. Vaktregelen holder: falsk-positiv-tallene og OKF-importen er
identiske. Flaggtabeller og figurer kalt `1a`, `1b` er tvillinger regelen ikke kan
skille fra en symbolkanal. De går til gjennomsyn og står i LIMITATIONS, sammen med
de målte omgåelsene (ordbok med ord, symboler på 3 tegn, en teller der stegene
bærer verdien).
- **Økt 7, instruksjoner i ren prosa:** A08, A09, A12, A16 og A19. Ingen av dem har
en markør. Her er det mest sannsynlig at flere ender som dokumenterte hull fordi
vaktregelen ikke holder. Det skal måles, ikke antas.
*Gjort 31.10 (1.8.0):* A08, A09, A16 og A19 fanges, og A12 er et dokumentert hull.
Dermed har rad 3 **17 fanget, 2 hull og 0 røde**. Hver rad fanges eller står som
hull, så rad 3 holder. `indirect:prose-instruction` (MEDIUM) melder et avsnitt som
kombinerer to av fire klasser: teksten snakker til modellen, den skal virke senere
(«from now on», «if the user later says»), den skriver til modellens minne, eller
den skjuler seg for brukeren. Én klasse alene er vanlig prosa. Målt før bygging:
ingen av de 5 494 godartede dokumentene har to ulike klasser i samme avsnitt. Det
gjelder de tre falsk-positiv-korpusene, 577 pakke-README-er og 1 412 skills, agenter
og kommandoer fra to plugin-samlinger som leverandøren selv publiserer (tekst som
per definisjon er instruksjoner til en agent). Med hele dokumentet som enhet fyrer
1 av 5 494. A12 har ingen av klassene: det er en forespørsel en vedlikeholder kunne
sendt, og regelen som ville fanget den, ville vært tilpasset ordene i nyttelasten.
Det er ført som sjuende hull i matrisen og i LIMITATIONS, sammen med de målte
omgåelsene (én klasse alene, omskriving, klassene i hvert sitt avsnitt, et annet
språk). Ett tillegg: ReDoS-sveipet gikk ikke inn i dict-er, så de fire nye mønstrene
ble ikke sveipet før det ble rettet. Nå sveipes 168 mønstre.
**Siste økt: sluttmåling.** Rad 1–3 telles på siste release med de frosne
nevnerne. Bare når alle tre holder, er retningen nådd.
Deretter **flyttes repoet til `open/`**. Operatøren flytter det på serveren. Økta
gjør tre ting først. Den sjekker den offentlige flaten for private stier,
koordineringsmetadata og økt-lenker, både i filene og i commit-meldingene. Den
retter prosjektreglene, slik at de sier at repoet er offentlig igjen. Og den sørger
for at installasjonsveiledningen peker dit repoet faktisk ligger. Etter flyttingen
verifiseres README og installasjonen **anonymt**.
*Målt 31.10 på 1.8.0 (`d43403b`): retningen er nådd.*
- **Rad 1: 16 av 16.** Ti er sjekket på nytt hos utgiveren i denne økta: ATLAS
(v2026.10), Unicode (18.0.0), de sju arXiv-versjonene og commons (v0.9.0). De
seks andre ble sjekket i økt 4 samme dag. `tests/test_catalog_register.py` er grønn.
- **Rad 2: 0.** Den frosne nevneren er uendret: 95 ved `6702d7a`, sha256
`da1aa0fa…6f`. Over arbeidstreet er det 0 med gammel og 0 med feil betydning.
- **Rad 3: 17 fanget, 2 hull (A12, A13), 0 røde.** Det er 19 rader innenfor, og
ingen av dem står med strict xfail. Matrisen er 144/144, og de 7 dokumenterte
hullene står.
- Vaktregelen: 1237 tester passerer, ReDoS-sveipet gir 0 flagg av 168 mønstre, og
census gir 0.
- **Den offentlige flaten:** i filene finnes ingen private stier, ingen økt-lenker og
ingen Keychain- eller postboksreferanser. `llms.txt` installerte v1.1.0. Pinnen er
rettet, og `tests/test_install_pins.py` holder nå alle installasjonspinner lik
`__version__`. Commit-meldingene: 16 commits fra 4.–5. juli bærer
`Claude-Session`-lenker. Alle ligger før `v0.1.0`, så hver eneste tagg bygger på
dem, og å fjerne dem krever at historikken skrives om. Det legges fram for
operatøren. Dokumentene nevner også andre av operatørens repoer ved navn
(commons, wiki-, okf- og linkedin-konsumentene). De fleste er ikke offentlige,
så for en leser er det navn uten adresse. Det er proveniens, ikke metadata, og
står uendret.
- **Etter flyttingen (01.11), anonymt verifisert:** operatøren flyttet repoet til
`open/` og gjorde det offentlig. Uten innlogging svarer API-et 200, repo-siden 200 og
README-en 200, og taggen `v1.8.0` peker på `d43403b`. Den gamle `ktg/`-adressen sender
videre med 301. Begge installasjonslinjene i README installerer 1.8.0 i hver sin rene
venv, uten git- og pip-oppsett og uten token: git-pinnen og indeksen. Den installerte
pakken stopper en injeksjon med `FAIL_SECURE` og slipper et rent sammendrag gjennom
med `WARN` («clean: no findings»), som er gulvet. Commitene fra juli står inntil
videre, og spørsmålet om historikken ligger hos operatøren.
## T.5 OWASP LLM Top 10: 2025 → 2026
Kilde: OWASPs 2026-utgave (PDF-innholdsfortegnelse, `2026/final/LLMxx_*.md` og
README i `GenAI-Security-Project/GenAI-LLM-Top10`). Hentet fra en kopi som ble
verifisert 02.10.2026. **Økt 1 re-verifiserer mot utgiveren før noe mappes.**
*Re-verifisert 28.10 mot utgiveren:* alle ti stemmer. Overskriftene i
`2026/final/*.md` er lest, og fillista er hentet live fra repoets API. Forordet
bekrefter flyttene. OWASP skriver selv `LLM01:2026`. Detaljer og bevis står i
`docs/threat-2026/catalogs.json`.
| 2025 | 2026 |
|---|---|
| LLM01 Prompt Injection | LLM01 Prompt Injection |
| LLM02 Sensitive Information Disclosure | LLM02 Sensitive Information Disclosure |
| LLM03 Supply Chain | LLM04 Supply Chain |
| LLM04 Data and Model Poisoning | LLM05 Data and Model Poisoning |
| LLM05 Improper Output Handling | LLM10 Improper Output Handling |
| LLM06 Excessive Agency | LLM03 Excessive Agency |
| LLM07 System Prompt Leakage | LLM08 Hidden Context Exposure |
| LLM08 Vector and Embedding Weaknesses | LLM09 Vector and Embedding Weaknesses |
| LLM09 Misinformation | LLM07 Misinformation |
| LLM10 Unbounded Consumption | LLM06 Unbounded Consumption |
## T.6 Åpne spørsmål
1. ~~Publiseres testsakene åpent?~~ **Ja**, operatøren 28.10 (§ T.3).
2. ~~Grensetilfellene i § T.3.~~ **Avgjort** av operatøren 28.10 (§ T.3).
3. ~~Tegnnevneren.~~ **Utvidet med Cf**, operatøren 28.10 (§ T.4 økt 1).
4. ~~Unntakene med uavklart kontekst.~~ **Avgjort** av operatøren 28.10 (§ T.4
økt 2): privat bruk fanges, talltegn unntas foran siffer, CJK-velgeren står.
---
# Implementation Plan — `llm-ingestion-guard` v1
Derived from `docs/BRIEF.md` plus a 2026-07-04 research pass (competitive landscape,
novelty/gap refutation, security-coverage gaps). This plan folds the verified gaps
into the v1 scope. The BRIEF is the design rationale; this is the actionable build.
## Resolved decisions (2026-07-04)
- **Name:** distribution `llm-ingestion-guard`, import `llm_ingestion_guard`. Repo
directory stays `llm-ingestion-pipeline-security`.
- **License:** MIT.
- **Lexicon storage:** shared JSON data + thin Python loader (single source of truth,
polyglot-ready for a later TS port).
- **Publish target:** Forgejo `open/` only (public). No PyPI, no GitHub. Install via
`pip install git+ssh://…/llm-ingestion-guard.git`.
- **Language:** Python-first, stdlib-only core; ML/judge detectors behind extras
(`[ml]`, `[judge]`), never in the core path.
## Positioning — A: "Guard the artifact"
> Query-time guardrails guard the answer. `llm-ingestion-guard` guards the **artifact**.
Lead with the **contract + placement** (write-path, pre-persist) + **failure-semantics**
(fail-secure toward the artifact, not fail-open toward a user). Detection is the weakest,
most-evadable layer — defense-in-depth, not the pitch.
Defensible claim, every qualifier load-bearing: *a dependency-light,
framework-agnostic **library** that packages the full write-time
injection-**containment** contract (quarantine → per-stage capability isolation →
scan-before-persist → fail-secure disposition) as composable code — the part
query-time tooling and single-stage detectors leave to the integrator.* Not "the
first" (an unverifiable temporal claim); the load-bearing qualifier is the *full
four-part contract*, verified against the 2026-07-15 survey (`docs/BRIEF.md` §11).
Cite OWASP LLM08:2025 / RAG Security Cheat Sheet for legitimacy; reference Dual-LLM
(Willison 2023) and CaMeL (DeepMind 2025) as architecture lineage — inspiration,
**not** equivalence.
### Claims we will NOT make (verified overclaim risks)
- NOT "prevents RAG/knowledge poisoning" — cannot catch **factual** poisoning
(PoisonedRAG: plausible false facts, no markers, low entropy).
- NOT "blocks prompt injection" — pattern/entropy detection is bypassable (arXiv
2504.11168, up to 100% evasion). Detection is a signal feeding disposition, never a
sole gate.
- NOT "invented" tool-less quarantine / capability isolation — prior art (Dual-LLM, CaMeL).
- NOT "the only" ingestion-security tool — PII-scoped libs (Philter, Presidio) and
services (AWS, HiddenLayer) exist; we are the minimal-dep *library* for the
injection-**containment** contract.
- Positioned as **complementary** to query-time guardrails (LLM Guard, Lakera,
LlamaFirewall), not a replacement.
## Module set (v1)
Core (BRIEF §5): `report`, `sanitize`, `fence`, `lexicon`, `entropy`, `contract`,
`output`, `disposition`.
Gap additions folded into v1 (from the security-coverage research):
- **`neutralize`** (new) — opt-in **pure** defang helpers for model OUTPUT: markdown
images, autolinks, `[ref]` links, `data:`/external URIs, raw HTML. Mutation is opt-in
and kept separate from the report-only gate, so design principle 3 (pure detection) and
4 (disposition belongs to the caller) hold. Closes EchoLeak-class (CVE-2025-32711)
persisted zero-click exfil — these are neither injection strings nor high-entropy, so
lexicon+entropy alone miss them.
- **`output`** extended — secret/PII egress patterns (OWASP LLM02); **decode-and-rescan**
(run the lexicon over decoded base64/hex, not just flag blob presence).
- **`fence`** hardened — randomized, unspoofable per-call delimiter; strip attacker fence
markers from the payload first.
- **Scanner self-safety** (OWASP LLM10) — input-size cap, ReDoS-safe patterns (bounded
quantifiers + per-scan guard), decompression guard. A scanner that hangs on crafted
input *is* the DoS.
- **`grounding`** (interface now, impl later) — a `SourceGroundingCheck` protocol in
core; ML/judge implementation behind the `[judge]` extra. The only structural handle on
semantic poisoning that lexicon+entropy cannot see. Ship the seam + honest scope note.
- **Chunk-aware / sliding-window scan** — option for split-payload evasion across chunk
boundaries.
- **`disposition` presets** — named source-trust tiers, including a **high-untrust
user-upload / open-source preset**: QUARANTINE_REVIEW as the default and **hard-fail on
CRITICAL**, not WARN. An automatic, unattended inbox that ingests arbitrary user uploads
is the canonical high-untrust consumer (§4.7) — over-blocking one upload is far cheaper
than persisting a poisoned one.
## Build order (TDD — a failing test FIRST for each)
1. `report` — findings / Report dataclass (shared return type)
2. `sanitize` — carrier stripping (zero-width, BIDI, Unicode-tag, HTML comment, `data:`);
byte-identical invariant on clean input, removes only
3. `entropy` — shannon / base64-like / hex-blob; decode-and-rescan support
4. `lexicon` — JSON data + loader + `scan`; ReDoS-safe; size cap; port from the
`llm-security` JS seed (CRITICAL/HIGH/MEDIUM/HYBRID + normalization/homoglyph/rot13/
unicode-tag)
5. `fence` — randomized delimiter; marker-strip
6. `neutralize` — opt-in defang helpers
7. `output` — compose lexicon+entropy over output; PII/secret egress; decode-and-rescan
8. `disposition` — WARN | QUARANTINE_REVIEW | FAIL_SECURE; source-trust + intra-document
provenance tiering (§4.7); compound-signal fail-secure (§4.6); fail-**closed** when the
scanner itself errors
9. `contract` — tool-less assert; per-stage credential allowlist; env-scoping helpers
10. `grounding` — protocol/interface only in core
11. Top-level `__init__` wiring the §6 checklist; `pyproject.toml`; `LICENSE`; README
(positioning + honest limitations); adversarial + false-positive corpora; the
end-to-end showcase pipeline test (see Test strategy — built LAST)
## Reuse map — `llm-security` v7.8.0 (MIT, same author)
Maximal reuse: most detection logic is a JS→Python **port**, not new code.
| Our module | Seed in `llm-security` | Notes |
|---|---|---|
| `lexicon` | `scanners/lib/injection-patterns.mjs` | CRITICAL/HIGH/MEDIUM/HYBRID tables + `scanForInjection` (variant-set: raw / normalized / homoglyph-folded / rot13, dedup by label) + `checkCognitiveLoadTrap`. The load-bearing port. |
| `sanitize` | `scanners/unicode-scanner.mjs` + `string-utils.mjs` | zero-width / BIDI / Unicode-tag / homoglyph detection + `stripBidiOverrides` / `decodeUnicodeTags` / `containsUnicodeTags`. |
| normalization | `string-utils.mjs` `normalizeForScan` | decode chain (unicode-tags → bidi → HTML-entities → unicode/hex/url escapes → base64), `foldHomoglyphs`, `rot13`, `collapseLetterSpacing`. Feeds decode-and-rescan. |
| `entropy` | `scanners/entropy-scanner.mjs` + `string-utils.mjs` | `shannonEntropy` / `isBase64Like` / `isHexBlob` / `tryDecodeBase64`; length-calibrated thresholds; `isFalsePositive` suppression rules; `DATA_URI_PREFIXES`. |
| `report` | `scanners/lib/output.mjs` + `severity.mjs` | `finding` / `scannerResult` envelope; `SEVERITY`; `riskScore` / `verdict` / `riskBand` / `owaspCategorize`. |
| `output` secret/PII egress | `knowledge/secrets-patterns.md` | Ready secret regex set (AWS/Azure/GCP/GitHub/npm/OpenAI/Anthropic/PEM/DB-conn/password/JWT) + FP-suppression rules. Seeds the LLM02 egress gap directly. |
| tests (corpora) | `examples/prompt-injection-showcase/payloads.json` | Labeled adversarial + false-positive corpus (category/severity/expected). Maps ~1:1 to §9. Plus `knowledge/{attack-mutations,attack-scenarios,signatures}.json`. |
| wiring reference | `hooks/pre-prompt-inject-scan.mjs` (input), `post-mcp-verify.mjs` (output gate) | How the scan is invoked at input vs output — informs our `output` module + the §6 checklist. |
**Port caveats (JS→Python, stdlib-only):**
- `/…/i` → `re.I`; `Buffer.from(s,'base64')` → `base64.b64decode`; `codePointAt`/`fromCodePoint` → `ord`/`chr`.
- `TOKEN_RE = /[\p{L}\p{N}_]+/gu` — Python `re` has no `\p{L}`; use `\w` under `re.UNICODE` (Py3 default) or `str.isalnum()`. No `regex` dependency.
- **ReDoS**: some patterns (sub-agent spawn with nested `.*?`) backtrack; Python `re` has no timeout → enforce input-size cap + review/bound these patterns (the self-safety must-have).
- Lexicon ships as **JSON** (regex source + label + severity), compiled by a thin Python loader — decouples data from engine for the future TS port.
## Test strategy (BRIEF §9 + additions)
- **Adversarial corpus** — one payload per lexicon class incl. obfuscated + multi-language;
measure and report *recall*.
- **False-positive corpus** — content legitimately discussing injection (security docs,
changelogs); assert WARN-not-block default; hard-fail is an explicit opt-in.
- **Sanitizer invariant** — clean input returns byte-identical with an all-zero report.
- **Contract asserters** — a tool-carrying request and a credential-leaking stage env both
raise; the happy path passes.
- **Self-safety** — pathological/ReDoS-prone and oversize input return within a bound,
never hang. Scope, measured 2026-07-31: the *lexicon* path is covered by
`test_redos_pathological_subagent_input_returns_fast` (crafted against a known-bad
nested `.*?`). The `output` path is covered by
`test_crafted_redos_payload_stays_bounded` — 15 crafted payloads plus one through the
composed gate. Writing them found the defect they were meant to rule out: 19 quadratic
runs across 17 patterns in `output` (4), `active_content` (5 patterns / 7 runs) and the
lexicon JSON (8), worst case ~5.7 hours at the 1_000_000-char cap the gate accepts. Note the shape, since it is
*not* the textbook one: no nested quantifier is involved. A run in front of a required
literal, reachable from a short anchor, is enough — crafted input repeats the anchor,
never supplies the literal, and every start position rescans the tail. The earlier
"output blob is slower than ordinary prose (0.93x/0.96x)" measurement stands and was
never wrong; it simply measured throughput on a blob, which is a different question
from what a crafted payload asks.
- **Neutralize** — active-content output is defanged; clean output is byte-identical.
- **End-to-end showcase (the FINAL deliverable, built last).** One realistic
piece of ingested content that carries *many* vulnerabilities at once — visible
+ zero-width/BIDI/Unicode-tag stego carriers, homoglyph + whole-string-base64 +
rot13-hidden injection, HTML-obfuscated and cognitive-load-buried payloads,
secret/PII egress in the enriched output, and active-content exfil links — run
through a mock ingestion pipeline (sanitize → lexicon+entropy+decode-rescan →
output gate → disposition). Assert every planted vulnerability is caught and
disposition fails secure. Doubles as the README's worked example. Inspiration:
`llm-security/examples/{prompt-injection-showcase,poisoned-claude-md,lethal-trifecta-walkthrough,toxic-agent-demo}`.
- **No network in any test.**
## README "Honest limitations" (shipped as a control)
Concede plainly (the concession prevents false assurance, which is itself a control):
structural unsolvability at the text layer; adversarial-ML evasion survives normalization;
tokenizer mismatch; semantic/factual poisoning invisible to lexicon+entropy; latent/dormant
memory poisoning not judgeable at write-time; insider in-place edits; text-only (no
multimodal).
## Out-of-scope (documented boundary)
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
steganography; query-time/runtime guardrails; semantic factuality verification. The
`contract` tool-less assertion is the write-time analogue of runtime least-privilege.
**Text-extraction boundary.** For pipelines that ingest arbitrary user uploads (an upload
inbox that ingests automatically), the library stays `text -> findings`: it does not parse
files (no `pypdf`/`python-docx`/archive deps in the core). The pipeline extracts text
first, then scans the extracted text + the enriched output with provenance tagged as the
high-untrust upload tier. Binary/multimodal carrier detection (OCR-embedded instructions in
images/PDFs, stego) is out of scope beyond the sanitizer's character-layer stripping and
the self-safety size/decompression guard — and must be conceded in the honest-limitations
section, because a high-untrust, unattended pipeline is exactly where assuming
uncovered-coverage is most dangerous.
## Threat-model anchors
OWASP LLM Top-10 2025: LLM01, LLM02, LLM04, LLM05, LLM06 (strongest coverage), LLM08
(boundary), LLM09, LLM10. Research anchors: PoisonedRAG (arXiv 2402.07867), guardrail
evasion (2504.11168), EchoLeak (CVE-2025-32711), RAGShield (2604.00387), CaMeL
(2503.18813), Dual-LLM (Willison), and the litellm supply-chain compromise (corroborates
the minimal-dependency thesis).
## v0.2+ stream sequencing (revised 2026-07-06)
v0.1.0 and v0.2.0 are tagged: the format-agnostic text core (modules 1–11,
`5397ba1` / released `df30c7b`) and the OKF adapter (stream 1, released
`542ac92`). The forward order was **re-sequenced on 2026-07-06** after a
ground-truth pass over the intended flagship consumer.
**Finding that drove the change.** The named flagship — `portfolio-optimiser`'s
"OKF-upload-inbox" — does not exist as a seam. Both optimiser siblings are frozen
at their release milestone, carry their *own* OKF layer (navigate + materialize
from trusted manifests), receive no *external* bundles, and take no dependency on
this guard; the operator's own registered future work for them does not include
it. Wiring an immature guard into two mature, spec-frozen repos would complicate
them for a consumer that is not asking for it. **Consumer integration is therefore
deferred until the guard is mature**; consumers are informed at the operator's
timing, not pushed.
Revised streams:
1. **OKF v0.2 hardening** — shipped (adapter + brief §8 tasks; T5b/B deferred to a
consumer that owns corpus storage).
2. **Mature the guard here, keeping it Node-port-friendly** — the near-term work.
The lexicon is already shared JSON (polyglot-ready); keep the `text -> findings`
surface clean and free of Python-only cleverness in the OKF layer, so stream 3
is a *translation*, not a redesign. The centrepiece is the **OKF inbox
showcase** (below): the guard demonstrating its own flagship use case
end-to-end, in-repo.
3. **Node/polyglot port** — the strategic enabler of painless integration (many
OKF consumers are Node/JS; the lexicon seed was `.mjs`). One polyglot repo over
the shared JSON lexicon; never split §13.3. Its API/lexicon contract should be
**scan-informed** (stream 4), not guessed.
4. **Pre-adaptation scan (operator-timed)** — at the operator's chosen point, scan
every repo and plugin that uses or plans Google OKF, then adapt the guard's
surface in advance so a later integration is painless. The scan is the input to
the "painless" guarantee: it grounds both the Node port's contract and any
consumer-specific seams before they are locked.
### Re-sequenced toward v1.0 (Fable cross-model review, 2026-07-09)
An independent cross-model review re-sequenced the forward path. Stream 2 is extended
with a set of review-injected hardening + coverage sessions (output-gate coverage,
OKF adapter hardening, egress decode-rescan, calibration-threshold consolidation,
docs/version-sync, and a novelty-claim verification) that land **before** a **v1.0
freeze**. The Node/TS port (stream 3) starts only **after** that freeze — porting a
frozen, scan-clean surface, never a moving target — over the shared JSON lexicon
(never split). Streams 4 + consumer integration are operator-timed "ambitious
extensions", not part of the v1.0 sequence.
The session-by-session plan and the review record live in `docs/` on the Forgejo
`open/` mirror alongside the other design docs — `docs/PLAN-v1.md` (the v1.0 + Node
session plan) and `docs/review-2026-07.md` (the review) — `open/` being the sole
sanctioned public surface (never GitHub).
### The OKF inbox showcase (next concrete build, TDD)
The flagship artifact we hand a consumer later — an in-repo end-to-end
demonstration of the mode-b receive/quarantine gate, mirroring
`tests/test_showcase.py` but for a *received external OKF bundle*. It composes the
public `okf` surface exactly as an "upload inbox" consumer would, so it doubles as
the README's OKF worked example. Every test is authored by us — the point is to
prove intent, not to coincidentally pass.
- **Composition** (`tests/test_okf_showcase.py`): an `_inbox(bundle)` helper
calling `okf.import_bundle(bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)`
and mapping the aggregate disposition — `WARN` → ADMIT, `QUARANTINE_REVIEW` →
HOLD, `FAIL_SECURE` → REJECT, any error → REJECT (fail-secure default).
- **One poisoned bundle** planting one attack per OKF surface at once, each with a
label proving it was caught/rejected:
- body injection (T1 scan) and frontmatter `description` injection (T1
whole-concept scan);
- a non-`https` `resource:` URL (T3 → FAIL_SECURE);
- a path-traversal concept key `../x.md` (T4 → FAIL_SECURE) and a reserved-name
shadow `index.md` / `log.md` (T4);
- a dangerous frontmatter value (anchor / alias / explicit tag, T2 → FAIL_SECURE);
- a dangerous-scheme cross-link `[x](javascript:…)` (T5a → `links.rejected`);
- a dangling cross-link to an absent concept (§7.2 dormant signal →
`links.dangling`);
- a carrier/obfuscation-hidden body injection (zero-width / homoglyph / rot13 /
whole-string base64) routed through `scan_concept`.
- **Assertions:** aggregate disposition is FAIL_SECURE; every planted OKF
vulnerability appears in the per-concept rejects / findings / link graph; a
**clean** bundle admits (WARN, no rejects, no dangling); `BundleResult.log()`
renders one line per concept with rejects marked. **Detach-proof:** neutering
`import_bundle` to always-admit makes the poison assertions fail.
- **Honest scope:** demonstrates the *structural + known-pattern* OKF surface only
— semantic/factual poisoning stays out (README honest-limits), consistent with
the core showcase.
#### Realistic upload formats — the two-stage inbox (extract → materialize → guard)
A human inbox does not receive tidy `{path: text}` dicts; it receives the files
people actually drop: `.txt`, `.md`, Word `.docx`, Excel `.xlsx`, PowerPoint
`.pptx`, `.pdf`, `.csv`, whole **folders**, and **`.zip`** archives. The showcase
therefore has two stages, honouring the locked text-extraction boundary (§
"Text-extraction boundary"): the guard core never grows a file parser.
1. **Extract & materialize (the inbox front-end).** Reads each dropped file, walks
folders, and *safely* unpacks archives; extracts text via format libraries
(`python-docx`, `openpyxl`, `python-pptx`; stdlib `zipfile`/`csv`); materializes
the result into an OKF bundle `{concept_path: text}` with provenance
(`origin=EXTERNAL`, source filename + type). **This stage owns the container and
format threats.** These libraries are **showcase/dev-scoped only** — never core
`dependencies` (which stays `[]`). Promotion to an optional `[extract]` extra is
a documented future option if a consumer wants turnkey extraction, not v1.
2. **Guard (`import_bundle`).** Scans every extracted concept + the OKF structural
gates → aggregate disposition. **This stage owns the text/structural threats.**
**One representative planted vector per format** (the point is where the payload
hides — the place a human does not look):
| Input | Hidden vector planted | Caught at |
|---|---|---|
| `.txt` | raw injection + carrier (zero-width / homoglyph / rot13 / base64) | guard scan |
| `.md` | OKF frontmatter attack (T2) + body injection (T1) | guard |
| `.docx` | injection in a **comment / hidden (vanish) run / core metadata property** | guard scan of extracted text |
| `.xlsx` | **formula injection** (`=cmd\|'…'`, `=HYPERLINK(…)`) + **hidden sheet / cell comment** | front-end + guard |
| `.pptx` | injection in **speaker notes / off-slide text box / image alt-text** | guard scan |
| `.pdf` | injection in extracted / **white-on-white** text | **conceded** — refused as an unsupported format, not extracted (see honest-scope below) |
| `.csv` | formula injection (`=`, `+`, `-`, `@` lead) | front-end + guard |
| **folder** | the OKF bundle-directory shape directly; one member path trips the path gate (T4) | guard |
| **`.zip`** | **zip-slip** entry `../../x.md` (maps straight onto OKFPathError, T4) + **zip-bomb** (bounded by the size cap / a safe-extract limit, LLM10) + symlink entry | front-end + guard |
**Assertions:** each planted vector is caught at the correct stage; the front-end
refuses zip-slip / zip-bomb / oversize fail-fast; the guard rejects
injection/carrier/frontmatter/resource/link/path; a **clean** file of every
*accepted* format admits (WARN). Detach-proof at **both** stages.
**Honest scope for uploads (must be conceded — README honest-limits).** What
survives text extraction is **out of scope**: VBA/macros (`.docm`/`.xlsm`/`.pptm`),
OLE / embedded objects, image-embedded instructions that need OCR, font/render
steganography, and encrypted/password-protected files. The guard scans *extracted
text*; binary-layer carriers need a separate scanner. **`.pdf` is conceded as a
format:** a top-level `.pdf` drop is refused as unsupported rather than extracted,
since a PDF parser + `reportlab` (fixtures only) is disproportionate for a dev-scoped
showcase and its OCR / font-render stego carriers are out of scope regardless. A
high-untrust, unattended
inbox is exactly where assuming uncovered-coverage is most dangerous (§4.7), so the
concession is itself a control.
**Deferred, unchanged:** T5b/B (the persisted cross-run link graph) waits for a
consumer that owns corpus storage; §7.2 dormant cross-run links remain a
documented residual until then.
**Splittable early win (optional):** text-only consumers can be wired at v0.1.0
today — for them the guard is already complete. Deferred with stream 2 by default;
pull forward only on explicit request.