Reusable, minimal, dependency-light defensive layer for LLM ingestion (write-time) pipelines — sanitize, fence, tool-less quarantined transform, capability isolation, output-scan-before-persist, fail-secure.
Find a file
Kjell Tore Guttormsen d73cfc882f docs(readme): lead with write-time trust-boundary; feature shipped OKF adapter
Rewrite the README value proposition to make the necessity land on mechanism,
not adjectives: write-time ingestion is the trust boundary query-time guardrails
structurally cannot see; an OKF/LLM-wiki has no format-level authenticity, so the
ingestion pipeline IS the trust boundary. Add a first-class 'OKF / LLM-wiki
support (shipped)' section for import_bundle mode-b (per-concept gates). Fix stale
test badge (357 -> 522) and drop the brittle module count. Tighten prose; retain
all honest-limitations items (a shipped control). Every symbol/preset/command
verified against v0.2 code.
2026-07-15 18:58:21 +02:00
docs docs(adoption): reusable consumer adoption brief for OKF second-brain repos 2026-07-15 12:35:09 +02:00
src/llm_ingestion_guard test(coverage): runnable threat-coverage matrix (real-case validation gate) 2026-07-15 11:20:22 +02:00
tests test(coverage): runnable threat-coverage matrix (real-case validation gate) 2026-07-15 11:20:22 +02:00
.gitignore chore: repo scaffolding via /repo-init 2026-07-04 08:39:23 +02:00
CHANGELOG.md docs(readme): lead with write-time trust-boundary; feature shipped OKF adapter 2026-07-15 18:58:21 +02:00
CLAUDE.md feat(egress): decode-rescan feeds base64 plaintext to secret-egress (review MINOR) 2026-07-15 07:11:29 +02:00
CONTRIBUTING.md docs: version-sync + SECURITY/CONTRIBUTING + honest-limits (Session E) 2026-07-15 10:08:24 +02:00
LICENSE feat: scaffold package + report and sanitize modules (TDD) 2026-07-04 09:24:20 +02:00
pyproject.toml feat(inbox): .xlsx extraction — formula gate, hidden sheets, cell comments (stage 2h) 2026-07-07 07:41:00 +02:00
README.md docs(readme): lead with write-time trust-boundary; feature shipped OKF adapter 2026-07-15 18:58:21 +02:00
SECURITY.md docs: version-sync + SECURITY/CONTRIBUTING + honest-limits (Session E) 2026-07-15 10:08:24 +02:00

llm-ingestion-guard

Version Status Python Tests License

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_bundle iterates 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).

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.

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. As of v0.2: 126 of 126 defended classes demonstrated (recall 100%) and 4 of 4 documented gaps still hold. 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.

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) 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, trust-scaling downgrades a single HIGH to WARN, and one HIGH is not "compound" (escalation needs ≥2 findings at MEDIUM+). So a HIGH injection reproduced verbatim under a trusted policy persists with a WARN. This is by design: if your "trusted" sources can carry attacker-influenced text, run them as untrusted (or add a quarantine floor).
  • The upload preset's quarantine floor is currently vacuous. PRESET_USER_UPLOAD sets quarantine_default, but detectors emit CRITICAL/HIGH/MEDIUM only, and under untrusted trust a MEDIUM already escalates to QUARANTINE_REVIEW — so the floor changes no outcome today. It is headroom for a future LOW/INFO finding, documented so the preset is not over-read.
  • Semantic / factual poisoning is invisible to lexicon + entropy: a false claim in clean prose carries no suspicious token. Highest impact for a wiki. The grounding module ships only a SourceGroundingCheck seam — 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. link_graph surfaces the dangling edge as the signal, but catching the payload needs cross-write re-scan over time (the caller's disposition call).
  • OKF reserved files (index.md / log.md). In a received bundle these are legitimate structure, so mode-b import_bundle scans their body and frontmatter (an injection in a directory listing is caught) rather than path-rejecting the conformant bundle. A front-end materialising individual uploads keeps the opposite rule (allow_reserved=False): a reserved basename is a listing-shadow and refused.
  • A document that describes attacks is a false positive. Content documenting prompt-injection payloads (security notes, this project's own corpus) trips carrier-strip / fail-secure. At the text layer "about an attack" and "carrying an attack" are indistinguishable; such content needs a deliberate, explicitly escaped path, never a silent allow.
  • Bilingual text trips the Cyrillic/Latin homoglyph rule. homoglyph:cyrillic-latin-mix (MEDIUM) flags a Latin letter adjacent to a Cyrillic look-alike, so genuine bilingual prose → MEDIUM → under untrusted → QUARANTINE_REVIEW — a real false positive for an inbox that expects multilingual content. A calibration fix is pending.
  • 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 (no pypdf/python-docx/archive deps). Extract text first, then scan it with the high-untrust upload provenance. OCR-embedded instructions and multimodal stego are out of scope beyond the sanitizer's character-layer stripping.
  • Uploaded files: only the extracted text is scanned. The dev-scoped OKF inbox showcase (tests/test_okf_inbox_uploads.py + tests/inbox_frontend.py; parsers in the [dev] extra, never core dependencies) reads .txt/.md/.csv/.docx/.pptx/.xlsx, folders and .zip, materializes an OKF bundle, then guards it. What survives extraction is out of scope: macros, OLE/embedded objects, OCR-needing images, font/render stego, encrypted 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). .pdf is a deliberate concession: a top-level .pdf is refused as unsupported rather than half-scanned (a PDF parser is disproportionate for a dev showcase, and the OCR/stego it would smuggle is already out of scope). One known gap: the numeric -/+ CSV false positive (a typed XLSX numeric cell does not trip it).
  • Lexicon findings are deduplicated by pattern idcount=1 and the first offset are reported, so a class matched across several channels collapses to one finding at its first location: a deliberate readability tradeoff.
  • Secret egress: base64-wrapped is caught, hex-wrapped is not. The output gate decodes base64 blobs and re-scans the plaintext, so a base64-wrapped secret surfaces as decoded:egress:*. entropy exposes decoded plaintext for base64 only, so hex (and other encodings, or nested wraps) is a deliberate boundary — 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.
  • 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.