- Python 100%
| docs | ||
| src/llm_ingestion_guard | ||
| tests | ||
| .gitignore | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| SECURITY.md | ||
llm-ingestion-guard
Write-time ingestion is the trust boundary that query-time guardrails structurally cannot see. When untrusted content passes through an LLM enrichment/summarization/extraction step into a persisted artifact — a RAG corpus, a knowledge base, an LLM wiki — the poisoned result is read later by a downstream agent as trusted context. That agent's guardrail never sees where the content came from. The only place the provenance still exists is the write.
This library packages that write-time contract — sanitize → fence → tool-less
quarantined transform → per-stage capability isolation → scan-before-commit →
fail-secure — as composable, stdlib-first, framework-agnostic code. It is the
write-time sibling of query-time tools (LLM Guard, NeMo Guardrails, Rebuff,
Vigil), not a competitor: those harden material as it enters the model; this
hardens it as it is committed for a later reader. Where existing OSS tooling is
mostly single-stage detectors — a risk verdict, with quarantine, capability
isolation, scan-before-persist, and fail-secure left to the integrator — this
packages the full contract as code. (Neighbours surveyed in
docs/BRIEF.md §11.)
Why an LLM wiki (e.g. Google OKF) needs this specifically. OKF and second-brain formats have no schema registry, no central authority, and no signing — a bundle's claimed origin is not verifiable at the format level. So your ingestion pipeline is the trust boundary: provenance must be stamped by you at write time, never assumed from the format. Any pipeline ingesting external data into an agent-read store has this shape; an OKF wiki is its canonical form — which is why the guard ships a first-class OKF adapter (below).
Status: v0.2, alpha. The stdlib-only core — its detector, contract, and
OKF-adapter modules plus the top-level wiring — is built and tested, 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) # sanitize + fence
enriched = your_model(prepared.fenced) # tool-less — YOUR call
decision = screen_output(enriched, PRESET_USER_UPLOAD) # scan + dispose
if decision.disposition is Disposition.FAIL_SECURE:
alert(gate_code=decision.reasons) # minimal payload, no content
raise SystemExit # 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.
OKF / LLM-wiki support (shipped)
For a bundle-shaped store, the okf submodule sits on top of the
format-agnostic core: it knows OKF structure (frontmatter, paths, links,
resource, bundles) and feeds scannable regions into the same sanitize /
scan_output / disposition machinery — no YAML/format awareness leaks into the
core. Two ingestion modes:
- (a) Own enrichment output — your agent writes concepts. Run the two bookends above per concept before commit.
- (b) Received external bundle — you merge a whole third-party OKF bundle.
import_bundleiterates concept-by-concept and runs the full per-concept gate:
from llm_ingestion_guard.okf import import_bundle, Origin, Channel
# bundle: {concept_path -> raw document text}, e.g. {"tables/users.md": "---\n..."}
result = import_bundle(bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)
for c in result.concepts:
if c.error: # hard reject: bad path, unsafe frontmatter, non-https resource
skip(c.path) # disposition is FAIL_SECURE; do not merge this concept
# result.disposition = most-severe across concepts
# result.links = the cross-link graph (dangling/rejected/resolved edges)
# result.log() = the log.md body (one provenance-stamped line per concept)
Per-concept gates: path / reserved-name (rejects .. traversal and reserved
index.md/log.md shadowing); frontmatter parse-safety — a strict,
reject-by-default loader that refuses anchors, aliases, and explicit tags by
construction, so a billion-laughs alias expansion or a !!python/object coercion
cannot occur (it is deliberately not a general YAML engine, whose own features
are the attack surface); resource https-allowlist (hard-rejects
data:/javascript:/file: before commit — a reject-gate, not defang);
whole-concept scan (frontmatter values + body through scan_output);
cross-link graph (surfaces dangling targets, the dormant-injection signal, and
rejects bundle-escaping links); provenance stamping (Origin × Channel →
tier + disposition, emitted to log.md).
What it protects against
Concrete attack classes, grouped by OWASP LLM Top-10 (2025) anchor. Every row is driven by a live payload in the coverage matrix — run it to watch all 126 pass in your own environment:
python -m llm_ingestion_guard.coverage # 126/126 classes; exit 0 = all as documented
| Anchor | Attack classes it stops (representative) |
|---|---|
| LLM01 · prompt injection | 83 instruction-override lexicon classes (ignore / forget / disregard / suspend-constraints, role-play, jailbreak, …); hidden carriers — zero-width stego, BIDI override, Unicode-tag stego, HTML-comment, data: URI — stripped on input and re-checked at the persist gate; high-entropy base64/hex blobs; an injection hidden inside a base64 blob (decoded, then re-scanned) |
| LLM02 · sensitive-info disclosure | Secret egress in output — AWS keys, tokens, private keys, the credential set; a base64-wrapped secret (decoded → decoded:egress:*) |
| LLM05 · improper output handling | Zero-click exfil carriers (EchoLeak, CVE-2025-32711) — markdown-image auto-fetch, inline / reference / autolink links, raw active HTML, standalone data: URIs; non-https resource URLs |
| LLM06 · excessive agency | A tool surface on the quarantined transform; credential use beyond the per-stage allowlist; capability isolation (scoped_env) |
| LLM10 · fail-secure | Forced-fallback attack (scan hit + transform failure → halt); compound weak-signal escalation; an un-scannable artifact fails closed — never a silent persist |
| OKF structure (T1–T6) | Body + frontmatter-value injection; YAML anchor / merge / nested / block DoS (parse-safety); resource allowlist; path traversal / reserved-name shadow; cross-link graph; provenance stamping |
| Container / upload layer | CSV/XLSX formula-injection (lead = + - @); zip-slip path escape; zip-bomb size cap; symlink refusal (upload front-end — see the showcase) |
The manifest (coverage.py) is the single
source of truth for
tests/test_coverage_matrix.py, which asserts
total recall over every class, that every documented gap still holds (a closed gap
fails the test), and that every lexicon pattern has a case — so the matrix cannot
fall behind the code. The four classes it deliberately does not stop are called
out in Honest limitations.
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
Conceding these plainly is itself a control — it prevents the false assurance that a green scan means safe content. The highest-impact items:
- The contract carries the security, not the lexicon. Pattern detection is bypassable in isolation (character-injection, novel phrasings); the tool-less transform + capability isolation + fail-secure are the wall.
- Semantic / factual poisoning is invisible. A false claim in clean prose
carries no suspicious token — the highest-impact gap for a wiki. Needs a
[judge]plugged into thegroundingseam. - Text-only, extracted-text-only. The core parses no files; OCR-embedded
instructions, macros, and multimodal stego are out of scope.
.pdfis refused as unsupported, not half-scanned. - A lone HIGH in trusted prose disposes to WARN, and insider in-place edits are outside the untrusted-content threat model — run genuinely untrusted sources as untrusted.
- Four documented gaps the coverage matrix keeps honest: hex-wrapped secret
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (
count=1).
Full list — 15 items, each with the mechanism, plus the out-of-scope boundary:
docs/LIMITATIONS.md.
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.
- Adoption brief — wiring the guard into an OKF second-brain / LLM wiki, and a checklist for when to include it.
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.