portfolio-optimiser/docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md
Kjell Tore Guttormsen 4ecd571961 docs(security): plan llm-ingestion-guard inclusion at ingest/inbox persist-gates
Map untrusted-ingest surface (ingest.materialize http/I6, verdict-inbox
load, promote_verdict, future received-bundle) vs first-party paths;
verdict = planned, wire scan/sanitize before M3 as S2.4/S2.5 extension.
Plan only — guard not wired. shared/ hardening owned by commons session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145ZKPLMVeqM47z2jxxokym
2026-07-16 07:43:30 +02:00

159 lines
13 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.

# Inkluderingsplan — `llm-ingestion-guard` som write-time persist-gate
> **Status:** PLAN (ikke implementert). Denne økta KARTLA repoets untrusted-ingest og la denne
> planen; guarden er IKKE wiret inn ennå (per adopsjons-briefens §7: «kun planlegg og oppdater
> planene»). Utløst av konsument-inkluderings-prompten fra `llm-ingestion-pipeline-security`.
> **Guard-modenhet:** `v0.2` (alpha) — stdlib-only kjerne, framework-agnostisk, offentlig API kan
> fortsatt endres. Pin ved adopsjon.
>
> **Kildepekere:** guardens adopsjons-brief (open-remote:
> `open/llm-ingestion-pipeline-security/docs/ADOPTION-BRIEF.md`). Guarden er allerede anerkjent i
> repoets veikart: [sesjonsplan Fase 26](2026-07-10-sesjonsplan-fase2-6.md) D-G («Guard forblir
> eget sikkerhets-repo (avhengighet)», linje 120) og [toolkit-repo-brief](2026-07-14-toolkit-repo-brief.md)
> §5/§8 («migreres IKKE; forblir eget repo … fabrikkens sikkerhetsvask krever `llm-ingestion-guard`
> som publisert»).
---
## 1. Hva guarden er, og hvorfor den er relevant HER
`llm-ingestion-guard` er **write-time**-søsknet til query-time chatbot-guardrails. Den sitter ikke
mellom bruker og modell ved spørring, men herder den andre formen: **untrusted innhold som flyter
gjennom et LLM-berikelses-/ekstraksjons-steg inn i et persistert, downstream-lest artefakt** — en
RAG-base, kunnskapsbase, wiki eller **OKF-bundle**.
Dette treffer arkitekturen vår direkte: `ingest.py` materialiserer eksterne kilder inn i OKF-bundles,
og optimiser-loopen konsumerer bundelen **uendret** som betrodd agent-kontekst (`bundle_context`).
En forgiftet konseptfil skrevet ved *write*-tid leses senere av forslagsstiller-agenten som **betrodd
kontekst** — agentens (framtidige) query-time-guardrail ser aldri hvor konseptet kom fra. Write-gaten
er det ENESTE stedet provenansen fortsatt er kjent. OKF har ingen skjema-registry, ingen sentral
autoritet og ingen signering — en mottatt bundles påståtte opphav er ikke verifiserbart på format-nivå.
Guarden gjør aldri modellkallet selv (relevant for kostdisiplinen: null modellkall, stdlib-only, ingen
nett). Den gir de to bibliotek-halvdelene rundt vår egen tool-less transform (`prepare_input` /
`screen_output`), pluss en OKF-adapter for hele bundles (`okf.import_bundle`).
## 2. §7-checklist scoret for dette repoet
| §7-kriterium | Repoet | Kilde |
|---|---|---|
| Persisterer LLM-beriket/eksternt-mottatt innhold i en store en *downstream* agent leser som betrodd | **JA** | `ingest.materialize` → OKF-bundle → `bundle_context` (run.py:302); verdict-inbox → `store.add``ExpeLContextProvider` (run.py:287-290) |
| **Minst én ingest-sti tar UNTRUSTED innhold** (ekstern URL / opplastet fil / mottatt bundle / auto-hentet web) — *den avgjørende* | **JA** | `http`-kilde (I6) auto-henter web/vendor-innhold (`read_http`/`_urllib_get`, ingest.py); verdict-inbox er «inbox som tar imot eksterne drops» (S2.5/F7/F11) |
| Et LLM-steg (summarize/extract/classify/rewrite) mellom untrusted kilde og store | Delvis — LLM-steget leser bundelen *downstream* (ingest er deterministisk, pre-loop); verdict-inbox injiseres direkte i agent-instruksjoner | ingest.py §1-docstring; verdicts.py:299-311 |
| Ønsker **fail-secure** (stopp før persist) framfor best-effort med stille commit | **JA** — hele repoets kontrakt er fail-fast/fail-closed (validator obligatorisk-blokkerende; `allow_network`; verdict-lag reservert) | CLAUDE.md-invarianter |
**Konklusjon: den avgjørende betingelsen holder → guarden er `applicable` (marker = `planned`).**
Ærlig motargument (vurdert, forkastet): man kunne hevde `not-applicable` fordi de eneste kildene som
kjøres i praksis i dag er operatør-forfattet lokal data (`reference_projects.json`, lokale CSV-er).
Men (a) `http`-konnektoren (I6) FINNES i koden og ER en untrusted auto-fetch-sti, og (b) verdict-inboxen
er eksplisitt anerkjent som en injeksjonsflate i veikartet (S2.5: «verdict-inboxen slutter å være en
uvalidert injeksjonsflate»). Derfor er `planned` den ærlige klassifiseringen — ikke `not-applicable`,
og ikke `integrated` (ingenting er wiret ennå).
## 3. Untrusted-grenser vs first-party
**Untrusted-grenser (guarden hører hjemme her) — persist-gater rangert etter eksponering:**
| # | Persist-gate | Fil | Hva krysser inn | Nåværende delforsvar | Gap guarden lukker |
|---|---|---|---|---|---|
| 1 | `ingest.materialize` / `read_http` | ingest.py:472 (http-gate 506-511) | Ekstern kilde (http/sql/csv) → `ingest-{id}.md` i bundelen → betrodd kontekst | `allow_network`-egress-gate, `safe_resolve`-baneinnesperring, kodefence-marker-avvisning, tabellcelle-escaping, verdict-lag-reservasjon | Ingen **carrier-strip** (zero-width/BIDI/Unicode-tag/HTML-comment/`data:`), ingen **lexicon/entropy/active-content-scan** av rendret kropp, ingen EchoLeak markdown-bilde/lenke-deteksjon. En zero-width-kodet instruksjon i en CSV-celle overlever i dag verbatim. |
| 2 | `load_verdicts_from_dir``store.add` | verdicts.py:181, kalt run.py:287-290 | Eksternt-droppet JSON (`id`+`rationale` verbatim) → store → injisert i agent-instruksjoner via `ExpeLContextProvider` | Tolerant skip, `decision`-vokabular (`{approved,rejected}`), `max_rationale_len`, `max_files` fail-fast (S2.5) | Ingen innholds-scan av `rationale`/`description` før den blir few-shot. Høyest frekvens i normal-loopen. |
| 3 | `promote_verdict``okf.write_concept_file` | verdicts.py:513 (skriv 563-564) | Godkjent dom → `type: verdict`-konseptfil i wikien → betrodd av ALLE framtidige kjøringer | Fail-closed HITL-gate (`PromotionRefused`, kun `approved`/`approved_with_adjustment`), nøytral index-label, `_safe_filename_token` | Innholdet stammer fra (untrusted) inboxen; guarden = defense-in-depth (scan `rationale`/`description` FØR permanent betrodd kontekst). Størst blast-radius. |
| 4 | Mottatt OKF-bundle (`okf.import_bundle`-mode) | *finnes ikke i dag* | En hel tredjeparts-bundle merges | — | Roadmapen ÅPNER den: D-H demo-sti («unzip energi-eksempel i bundle-innboks»), D7-portabilitet, toolkit-fabrikk. «Når, ikke hvis»: bruk `okf.import_bundle(origin=EXTERNAL)` per-konsept-gate når en received-bundle-sti legges til. |
**First-party (guarden gjelder IKKE):**
- **`datasource.py` / `reference_domain.py`** — operatør/syntetisk data, JSON-Schema-validert fail-fast. Deployerens egen kontekst.
- **`file`/`sql`-kilder mot operatørens egen lokale katalog** — first-party *etter opphav*. Tillit følger dataens opphav, ikke innsettings-kanalen: en CSV lastet ned fra en vendor er fortsatt ekstern og hører under gate 1. Deployeren eier klassifiseringen (jf. «rent teknisk rammeverk»-invarianten).
- **`write_verdict`** (verdicts.py:161) — forfatter-primitiv; tillitsgrensen er *lastingen* (gate 2), ikke skrivingen.
- **`outbox.write_outbox`** — systemets EGET output-lag (må aldri re-ingesteres som inbox, run.py:261-263).
- **`persona.load_persona_example`** (persona.py:47) — first-party fil-les; men NB: i simuleringen forplanter den seg gjennom `promote_verdict` inn i wiki/ExpeL-kjeden — dekkes indirekte av gate 3.
- **Optimiser-loopens interne resonnement** (proposer/debatt/validator) — intet eksternt innhold hentes; resonnement over allerede-materialisert bundle-kontekst.
## 4. NÅR — innplassering i veikartet
Guarden er en **persist-gate**, så den skal wires **før den første untrusted-ingest-stien går live**,
festet der beriket/mottatt innhold committes. Veikartet gir tidspunktet eksplisitt:
- **Hard grense: FØR M3** (Fase 6-pilot). M3 er der «én ekte kilde … herdet og materialisert med ekte
provenance; én ekte fagekspert onboardet på inbox/outbox-kontrakten» — dvs. første ekte untrusted-ingest
(gate 1) OG første ekte inbox-drop (gate 2) samtidig. Guarden MÅ stå før M3.
- **Naturlige wiring-økter (utvider eksisterende steg, ingen ny fase):**
- **Gate 1 → utvid S2.4** («Konnektor-herding mot mock»): S2.4 herder allerede http-konnektoren mot
injisert transport. Legg guardens `sanitize` + `scan_output` på rendret ekstraksjons-kropp der.
- **Gate 2 → utvid S2.5** («Inbox-herding»): S2.5 gjør inboxen til «ikke lenger uvalidert injeksjonsflate»
med vokabular + lengde-tak. Guardens innholds-scan av `rationale`/`description` er den manglende brikka.
- **Gate 3 → ny opt-in ved `promote_verdict`**: defense-in-depth-scan, opt-in (speiler at `promote_verdict`
selv er opt-in, ikke wiret i `run_project`).
- **Gate 4 → når en received-bundle-sti faktisk legges til** (D-H demo-sti / toolkit-fabrikk): `okf.import_bundle`.
- **Avhengighet allerede erklært:** guarden er «eget sikkerhets-repo (avhengighet)» (D-G) og toolkit-fabrikkens
«sikkerhetsvask krever `llm-ingestion-guard` som publisert» (toolkit-brief §8). Denne planen KONKRETISERER
hvor i DETTE repoet den avhengigheten lander.
Rekkefølge-anbefaling: gate 2 (S2.5) og gate 1 (S2.4) først (de er allerede planlagte herdings-økter i
Fase 2-bolk B, gated på D-B), gate 3 som liten opt-in når som helst, gate 4 kun når received-bundle åpnes.
Ingen av dem er på kritisk sti før M3-forberedelsen begynner.
## 5. HVOR + minimal wiring (per gate)
Skjematisk (adopsjons-briefens §2/§4 API; IKKE implementer nå):
**Gate 1 — `ingest.materialize` (staging-loopen, før `_write_bytes`):**
```
prepared = prepare_input(raw_source_text) # carrier-strip + fence FØR render
# ... eksisterende render_table / _render_fenced_block på prepared ...
decision = screen_output(rendered_body, PRESET_*) # scan rendret konseptkropp
if decision.disposition is Disposition.FAIL_SECURE:
raise IngestError(gate_code=decision.reasons) # fail-fast, matcher modulens kontrakt
```
Fail-secure = `IngestError` (byttet inn i modulens eksisterende fail-fast-mønster). Alert-payload = gate-kode
+ run-id, ALDRI innhold (§8).
**Gate 2 — `load_verdicts_from_dir` (i skip-loopen):** kjør `rationale`/`description` gjennom `screen_output`
med EXTERNAL/MANUAL-provenance; fail-secure = **skip fila** (konsistent med den eksisterende tolerante
skip-kontrakten Steg-7-loopen hviler på) + logg gate-kode. Aldri raise (bevarer per-fil-toleransen).
**Gate 3 — `promote_verdict` (opt-in, før `write_concept_file`):** `screen_output``rationale` → fail-secure
hever `PromotionRefused` (gjenbruk eksisterende fail-closed-sti).
**Gate 4 — received-bundle:** `okf.import_bundle(bundle, origin=EXTERNAL, channel=…)` per-konsept-gate FØR
merge; `c.error`-konsepter skippes (FAIL_SECURE), aldri merget.
## 6. Invariant-forenlighet (må holde ved wiring)
- **MAF-fri / D7-portabel:** guarden er stdlib-only, null deps → forenlig med `ingest.py`/`okf.py`'s
«pure stdlib + pydantic, MAF-fri»-krav (håndhevet av `test_okf_is_maf_free` + `test_ingest_loadbearing`).
Legg guarden til i `_MAF_FREE_MODULES`-probens tillatte tredjeparts-deps eller verifiser at den ikke
drar inn `agent_framework`/`mcp` (den gjør ikke). **D7-speiling påkrevd:** SDK-søsknet må wire samme guard
(framework-agnostisk → samme pakke) i egen økt.
- **`shared/` er PULL-ONLY + commons-koordinering:** ingest-SPEC-en bor i `shared/ingest-spec.md` (frossen,
commons). Hvis wiring krever et spec-felt (f.eks. `screened_at`/trust-tier i provenance-frontmatteren),
MÅ amendmenten skrives i `portfolio-optimiser-commons` og pulles hit — aldri lokalt. **NB:** samme
inkluderings-prompt kjører i commons-repoet; koordiner så guard-vask i den delte `shared/`-halvdelen
(persona/skills, ekspert-artefakt) eies der, og denne planen kun dekker repo-lokale moduler.
- **Alpha-pin:** `v0.2` — pin eksakt versjon i `pyproject.toml`; behandle API som ustabilt til v1.0.
- **Kostdisiplin:** guarden gjør null modellkall og null nett → forenlig med offline-først. Semantisk/faktisk
forgiftning er USYNLIG for lexicon+entropy (guardens ærlige grense §8) — den fanges av HITL-dommen +
validatoren, ikke guarden. Ikke overselg: guarden er defense-in-depth på tekst-laget, ikke en vegg.
## 7. IKKE i scope nå (scope-grense)
- Ingen kildekode-endring i denne økta. Ingen ny dependency lagt til `pyproject.toml`.
- Ingen endring i `shared/` eller ingest-spec (commons, PULL-ONLY).
- Ingen wiring i `run_project`.
- **Åpne operatør-beslutninger (ikke-blokkerende):** (a) hvilke gater som faktisk skal wires ved M3-forberedelse
(anbefaling: gate 1+2 obligatorisk, gate 3 opt-in, gate 4 kun ved received-bundle); (b) om et
`screened_at`-provenance-felt skal inn i ingest-spec-en (krever commons-amendment — koordiner med D-B/S2.4).
## 8. Verifisering (testbare kriterier — for den framtidige wiring-økta, ikke nå)
Når guarden faktisk wires (S2.4/S2.5-utvidelse), skal disse holde:
- **Load-bearing detach:** en test der en kjent injeksjons-payload (zero-width-kodet instruksjon i en
CSV-celle / `rationale`) MÅ gi FAIL_SECURE (gate 1: `IngestError`; gate 2: skip); detach guard-kallet →
payloaden når bundelen/storen → RØD.
- **Fail-secure retning:** gate 1 raiser (fail-fast), gate 2 skipper (tolerant) — bevist per gate.
- **Ingen regresjon:** `test_okf_is_maf_free` + `test_ingest_loadbearing` grønne (guarden drar ikke inn MAF).
- **Determinisme/offline:** `grep`-guard bekrefter ingen ny socket-sti; guard-kallet gjør null nett/modell.
- **Denne planen (nå):** `docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md` finnes; STATE.md bærer
markørlinja `llm-ingestion-guard: planned — …` (grep-bar av guard-repoets rollup).