Add a single declarative manifest proving, in one place, every vulnerability class the guard stops — and the documented gaps it does not. This is the real-case validation gate ahead of any v1.0 freeze (v1.0 stays parked until verified on real cases). - src/llm_ingestion_guard/coverage.py: stdlib-only manifest (CORE_CASES) + narrated runner. `python -m llm_ingestion_guard.coverage` prints class -> OWASP -> expected -> observed -> verdict; exit 0 iff every caught class is caught and every documented gap holds. 126 caught classes + 4 gaps. Lexicon cases are generated from load_lexicon() via a payload dict, so a pattern with no payload fails loudly at import (self-verifying). - tests/test_coverage_matrix.py: asserts total recall, that every documented gap still holds, and completeness (every lexicon id + every OWASP anchor has a case). Adds the full 25-pattern LLM02 secret-egress set (fixtures assembled from split tokens so no secret shape sits in source) and the container-layer front-end classes (CSV formula-injection, zip-slip, zip-bomb, symlink). - README + CHANGELOG: point to the runnable matrix. +165 tests (357 -> 522). No core dependency added.
13 KiB
llm-ingestion-guard
A reusable, minimal, dependency-light defensive layer for LLM ingestion pipelines — the write-time siblings of query-time chatbot guardrails.
Where mature guardrails (LLM Guard, NeMo Guardrails, Rebuff, Vigil, …) sit between a user and a model at query time, this library hardens the other shape: untrusted content flowing through an LLM enrichment/summarization/extraction step into a persisted, downstream-consumed artifact (RAG corpus, knowledge base, wiki). It packages the architectural contract — sanitize → fence → tool-less quarantined transform → per-stage capability isolation → scan output before commit → fail-secure — as composable, stdlib-first, framework-agnostic code.
The gap it fills is not "no one detects injection." Existing OSS tools are
either single-stage detectors — they emit a risk verdict and leave quarantine,
capability isolation, scan-before-persist, and fail-secure disposition to the
integrator — or runtime content-hardening that guards material as it enters the
model. This library packages the full write-time ingestion contract as
composable code: the part query-time tooling structurally cannot see, because a
poisoned artifact committed at write time is read by a downstream agent whose
guardrail never sees where it came from. (Nearest neighbours surveyed in
docs/BRIEF.md §11.)
Status: v0.2, alpha. The stdlib-only core is built and tested — ten
detector/contract modules and the top-level wiring, exercised by an end-to-end
showcase and adversarial + false-positive corpora. The public API may still
change. There are real limitations, stated plainly below; read them.
Install
pip install llm-ingestion-guard # stdlib-only core, zero dependencies
Optional ML/judge detectors live behind extras ([ml], [judge]) and are not
required — the core is deterministic and dependency-free.
Quickstart — the two bookends
The library never makes the model call itself. It gives you the two library-side halves around your own tool-less transform:
from llm_ingestion_guard import (
prepare_input, screen_output, Disposition, PRESET_USER_UPLOAD,
)
prepared = prepare_input(untrusted_content) # §6 1-2: sanitize + fence
enriched = your_model(prepared.fenced) # §6 3: tool-less — YOUR call
decision = screen_output(enriched, PRESET_USER_UPLOAD) # §6 6-7: scan + dispose
if decision.disposition is Disposition.FAIL_SECURE:
alert(gate_code=decision.reasons) # §6 8: minimal payload, no content
raise SystemExit # §6 7: halt — never persist
screen_output fails closed: if the scanner itself errors on crafted input,
the disposition is FAIL_SECURE, never a silent persist. Pass
transform_failed=True when your model call raised or fell back — a scan hit
together with a transform failure is treated as a probable forced-fallback attack
and halts regardless of trust tier.
Every primitive is also exported for pipelines that compose the checklist
themselves — sanitize, scan_lexicon, scan_entropy, scan_output,
scan_active_content, neutralize, the decide / guard disposition
machinery, and the contract asserters assert_tool_less /
assert_credential_allowlist / scoped_env. See
the end-to-end showcase for a full worked pipeline.
Verify what it stops (coverage matrix)
Before wiring the guard into your pipeline, see — in one place — every vulnerability class it stops, and the ones it deliberately does not:
python -m llm_ingestion_guard.coverage # prints the matrix; exit 0 = all as documented
Each row drives the real guard with a live payload and prints
class -> OWASP anchor -> expected -> observed -> verdict. The manifest
(coverage.py) is the single source of
truth for tests/test_coverage_matrix.py, which
asserts total recall across every defended class, that every documented gap
still holds (a closed gap fails the test, forcing a doc update), and — the
honesty guard — that every lexicon pattern has a case so the matrix cannot
fall behind the lexicon. The full LLM02 secret-egress set and the container-layer
front-end (CSV formula-injection, zip-slip/bomb, symlink) are asserted there too.
The reusable contract (adopt-this checklist)
The actual product is this checklist, encoded as code you wire in order:
- Sanitize before fence. Strip carrier classes (zero-width, BIDI,
Unicode-tag, HTML comment,
data:) from untrusted input first. - Fence untrusted input. Spotlight-mark it in a randomized per-call delimiter; strip attacker fence markers from the payload.
- Tool-less transform. Call the model with zero tools. A successful injection then has nothing to act with.
- Per-stage capability isolation. The enrichment stage holds only the model key; the publish stage holds only the publish credential; no stage holds both.
- Treat output as data. Parse to a frozen schema; reject on structural violation. The output never reaches a shell, git, or a filesystem path.
- Scan output before persist. Run the lexicon + entropy + active-content
scan over the emitted text. Verbatim-carried payloads, model-emitted
instructions, and zero-click exfil carriers (EchoLeak-class markdown
images/links, raw active HTML,
data:URIs) are caught here;neutralizeadditionally defangs them, opt-in. - Fail-secure on compound signals. Injection hit + transform failure = halt
- alert, never a silent verbatim commit.
- Minimal alert payloads. Alert with a gate code + run ID, never content.
Steps 1-2 are prepare_input; steps 6-7 are screen_output; steps 3-5 are
yours; the contract asserters harden step 3-4.
Honest limitations (shipped as a control)
Conceding these plainly is itself a control — it prevents the false assurance that a green scan means safe content:
- Structural unsolvability at the text layer. Pattern/lexicon detection is bypassable in isolation; character-injection and novel phrasings evade it. The contract (tool-less transform, capability isolation, fail-secure) is what carries the security — the lexicon is defense-in-depth, not a wall.
- A lone HIGH finding in trusted prose disposes to WARN, not quarantine.
Under
PRESET_TRUSTED_SOURCE, §4.7 trust-scaling downgrades a single HIGH to WARN (a reputable single-author source is expected to carry security vocabulary), and one HIGH is not "compound" — compound escalation needs ≥2 findings at MEDIUM+. So a HIGH-severity injection reproduced verbatim in output under a trusted policy persists with only a WARN. This is the §4.7 design, not a miss: if your "trusted" sources can carry attacker-influenced text, run them as untrusted (or add a quarantine floor) instead. - The upload preset's quarantine floor is currently vacuous.
PRESET_USER_UPLOADsetsquarantine_default("any finding → at least QUARANTINE_REVIEW"), but every detector emits CRITICAL/HIGH/MEDIUM only (no LOW/INFO), and under untrusted trust a MEDIUM already escalates to QUARANTINE_REVIEW. The floor therefore never changes an outcome in today's severity set — it is defensive headroom for a future LOW/INFO finding (e.g. a grounding "unchecked" marker), not an active control. Documented so the preset's guarantee is not over-read. - Semantic / factual poisoning is invisible to lexicon + entropy: a
factually false claim in clean prose carries no suspicious token. The
groundingmodule ships only aSourceGroundingCheckseam — the deterministic core does not judge semantics; a[judge]implementation must be plugged in. - Adversarial-ML evasion can survive normalization; tokenizer mismatch between scanner and model leaves gaps.
- Latent / dormant memory poisoning is not judgeable at write time.
- Dormant / broken-link injection in a linked corpus (e.g. an OKF bundle): a link to a not-yet-existing target passes a per-concept write-time scan clean — the payload is planted later, when that target is written. A scanner that sees one document at a time cannot catch it; it needs cross-write graph re-scan (tracked for the OKF adapter).
- OKF reserved files (
index.md/log.md). In a received bundle these are legitimate structure (directory listing, update log), so mode-bimport_bundlescans their body and frontmatter — an injection planted in a directory listing is caught — rather than path-rejecting the whole conformant bundle. The upload front-end keeps the opposite rule: an individual upload landing on a reserved basename is a shadow of the listing and is refused (allow_reserved=False). - A document that describes attacks is a false positive. Content whose legitimate purpose is to document prompt-injection payloads (security notes, this project's own corpus) trips carrier-strip / fail-secure. At the text layer there is no way to distinguish "about an attack" from "carrying an attack"; such content needs a deliberate, explicitly-marked escaped path, never a silent allow.
- Bilingual text trips the Cyrillic/Latin homoglyph rule.
homoglyph:cyrillic-latin-mix(MEDIUM) flags any Latin letter adjacent to a Cyrillic look-alike, so genuine bilingual prose (e.g. Russian, or mixed Norwegian/Cyrillic) trips MEDIUM → under untrusted → QUARANTINE_REVIEW — a real false positive for an inbox that expects multilingual content. A calibration fix is pending (require ≥N mixed pairs, or only flag when a folded variant also hits another pattern); until then, multilingual corpora over-quarantine on this rule. - Insider in-place edits by a trusted author are out of the untrusted-content threat model.
- Text-only. The core is
text -> findings: it parses no files (nopypdf/python-docx/archive deps). Extract text first, then scan it with the high-untrust upload provenance. OCR-embedded instructions and multimodal stego in images/PDFs are out of scope beyond the sanitizer's character-layer stripping. - Uploaded files: only the extracted text is scanned. The two-stage OKF
inbox showcase (
tests/test_okf_inbox_uploads.py+tests/inbox_frontend.py, a dev-scoped demonstration whosepython-docx/python-pptxparsers live in the[dev]extra, never coredependencies) reads.txt/.md/.csv/.docx/.pptx/.xlsx, folders and.zip, materializes them into an OKF bundle, then guards it. What survives text extraction is out of scope: VBA/macros (.docm/.xlsm/.pptm), OLE / embedded objects, image-embedded instructions needing OCR, font/render steganography, and encrypted / password-protected files — the binary layer needs a separate scanner. The front-end owns the container threats it can see (zip-slip → path gate, zip-bomb → size cap, symlink refusal, CSV/XLSX formula-lead cells)..pdfis a deliberate concession, not a TODO: a top-level.pdfdrop is refused as an unsupported format rather than half-scanned, because a PDF parser (plusreportlabsolely to author white-on-white test fixtures) is disproportionate for a dev-scoped showcase, and the OCR / font-render stego carriers a PDF would smuggle are already out of scope above. The one remaining known gap is the numeric-/+CSV false positive (an XLSX numeric cell is typed, so it does not trip the gate). - Lexicon findings are deduplicated by pattern id —
count=1and the first offset are reported, so the same class matched across several channels/variants collapses to one finding at its first location. This keeps reports readable, but a caller that counts occurrences or needs every offset of a repeated pattern sees only the first: a deliberate readability tradeoff, not full positional coverage. - Secret egress: base64-wrapped is caught, hex-wrapped is not. The output gate
decodes base64 blobs and re-scans the plaintext through the credential/egress set,
so a base64-wrapped secret surfaces as
decoded:egress:*rather than vanishing. A hex-wrapped secret does not:entropyexposes decoded plaintext for base64 only, so hex (and other encodings, or nested wraps) is a deliberate boundary, not a silent miss — decode the transport layer first if you need it scanned.
Out-of-scope (documented boundary)
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal steganography; query-time / runtime guardrails; semantic factuality verification.
Design & threat model
- Design brief — what this repo contains and why.
- Build plan — module build order and the reuse map.
The contract is extracted from a working reference implementation (the
claude-code-llm-wiki Stage B enrichment pipeline). Threat-model anchors: OWASP
LLM Top-10 2025 (LLM01/02/04/05/06 strongest, LLM08 boundary, LLM09/10),
PoisonedRAG, guardrail-evasion (arXiv 2504.11168), EchoLeak (CVE-2025-32711).
License
MIT — see LICENSE.