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 d39033676a docs(plan): the v1.0 gate cannot be opened by a green result alone
okf pins llm-ingestion-guard >=0.2,<0.3 in [project].dependencies and pins the
uv source to tag v0.2.0 -- verified at their v0.4.0 tag and at their HEAD. The
cap excludes 0.3.1, so the committed Door B+C fixture run cannot reach the
version the gate names. It does not fail either: uv resolves v0.2.0, the
constraint holds, and the fixtures come back green because v0.2.0 never had the
regression. Green from an unmodified tree is the one outcome that proves nothing
and reads as proof.

The gate now requires the result to name the resolved guard version, and records
that transitive arrival does not count as an integration -- okf v0.4.0 makes the
guard a mandatory runtime dependency, so a consumer can acquire us without
choosing us, and today lands on v0.2.0 without the 0.3.0 hardening or the 0.3.1
fix. Silent under-defend, not a break.
2026-07-25 19:57:41 +02:00
docs docs(plan): the v1.0 gate cannot be opened by a green result alone 2026-07-25 19:57:41 +02:00
src/llm_ingestion_guard fix(calibration): grade active content on URL shape, not construct type 2026-07-25 15:36:02 +02:00
tests fix(calibration): grade active content on URL shape, not construct type 2026-07-25 15:36:02 +02:00
.gitignore chore(gitignore): ignore *.local.sh (local-only operator scripts) 2026-07-15 19:47:34 +02:00
CHANGELOG.md fix(calibration): grade active content on URL shape, not construct type 2026-07-25 15:36:02 +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 fix(calibration): grade active content on URL shape, not construct type 2026-07-25 15:36:02 +02:00
README.md fix(calibration): grade active content on URL shape, not construct type 2026-07-25 15:36:02 +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.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:

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:

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

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 (T1T6) 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:

  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 — 18 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.