# llm-ingestion-guard ![Version](https://img.shields.io/badge/version-0.3.1-blue) ![Status](https://img.shields.io/badge/status-alpha-orange) ![Python](https://img.shields.io/badge/python-3.10%2B-purple) ![Tests](https://img.shields.io/badge/tests-577_passing-green) ![License](https://img.shields.io/badge/license-MIT-lightgrey) **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`](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.3`, 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 Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag: ```bash pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.1" ``` The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or other credential. The core is stdlib-only with zero dependencies, so nothing else resolves. Optional ML/judge detectors live behind extras (`[ml]`, `[judge]`) and are not required — the core is deterministic and dependency-free. **Tags are the stable contract.** Every release is version-synced before tagging, and published tags are never moved. Two consequences worth knowing before you depend on this: - A git URL is a PEP 508 *direct reference*: it pins one exact tag, not a range like `>=0.2,<0.3`. Real range pinning — and therefore automatic pickup of patch releases — arrives with a Forgejo PyPI registry, which becomes the durable channel at the first patch release or the second downstream consumer, whichever comes first. The distribution name (`llm-ingestion-guard`) and the version scheme are unchanged by that move, so pins written today keep their meaning. - **Vendoring the source into a consumer is not supported.** It severs the patch channel that a shared security dependency exists to provide: a copied guard keeps running the vulnerabilities the original has already fixed. ## 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: ```python 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](tests/test_showcase.py) 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_bundle` iterates concept-by-concept and runs the full per-concept gate: ```python 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: ```bash 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`](src/llm_ingestion_guard/coverage.py)) is the single source of truth for [`tests/test_coverage_matrix.py`](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](#honest-limitations). ## The reusable contract (adopt-this checklist) The actual product is this checklist, encoded as code you wire in order: 1. **Sanitize before fence.** Strip carrier classes (zero-width, BIDI, Unicode-tag, HTML comment, `data:`) from untrusted input first. 2. **Fence untrusted input.** Spotlight-mark it in a randomized per-call delimiter; strip attacker fence markers from the payload. 3. **Tool-less transform.** Call the model with zero tools. A successful injection then has nothing to act with. 4. **Per-stage capability isolation.** The enrichment stage holds only the model key; the publish stage holds only the publish credential; no stage holds both. 5. **Treat output as data.** Parse to a frozen schema; reject on structural violation. The output never reaches a shell, git, or a filesystem path. 6. **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; `neutralize` additionally defangs them, opt-in. 7. **Fail-secure on compound signals.** Injection hit + transform failure = halt + alert, never a silent verbatim commit. 8. **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 the `grounding` seam. - **Text-only, extracted-text-only.** The core parses no files; OCR-embedded instructions, macros, and multimodal stego are out of scope. `.pdf` is 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. - **Active-content severity grades on URL shape, not construct type.** A URL that only *names* a remote document is LOW; one that can carry a value outward keeps HIGH/MEDIUM. The conceded hole: a bare-path image on a hostile host still *fetches* when rendered, so pure beaconing (reader IP, timing) is not graded. - **Six documented gaps** the coverage matrix keeps honest: hex-wrapped secret egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`), pure beaconing, and short opaque URL segments. **Full list — 27 items, each with the mechanism, plus the out-of-scope boundary:** [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from consumer corpora, including the false positives the URL-shape rule actually produces. ## 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](docs/BRIEF.md) — what this repo contains and why. - [URL-shape rule](docs/URL-SHAPE.md) — `is_ordinary_url` stated precisely enough to reconstruct, with worked examples asserted against the implementation. **Read this before reasoning about the rule from prose:** three consumers reconstructed it from summaries and each produced a different wrong number on a real corpus. - [Build plan](docs/PLAN.md) — module build order and the reuse map. - [Adoption brief](docs/ADOPTION-BRIEF.md) — 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](LICENSE).