1
0
Fork 0
llm-ingestion-pipeline-secu.../README.md
Kjell Tore Guttormsen 0eab4345bf
feat(output): decode hex-wrapped credentials at the persist gate
Gap 1 closed. The output gate finds runs of 32+ hex digits
(calibration.HEX_WRAP_MIN_LEN), decodes both byte alignments, keeps text
that is at least 80% printable (the base64 bar, now one shared helper in
entropy) and runs the credential patterns on it. A hex-wrapped AWS key
surfaces as decoded:egress:aws-access-key-id with "in hex run @ N" as
evidence; the value is never in it. Credentials only: a hex-wrapped
injection is not decoded.

Measured before and after on the same trees: all 3583 clean documents
(three FP populations and the 78 pinned OKF concepts) are identical line
for line under both presets (disposition, labels, severity, count,
offset); OKF import stays 47/31. They and 569 READMEs hold 2403 hex runs
of 32+ digits, none of which decodes to text. scan_output CPU 122.9 s
before, 123.4 s after; one solid hex run at the 1M cap 4.2 s -> 5.8 s.

14 new tests (10 red for the right reason first), 6 of 6 mutations red.
Matrix 146/146 + 5/5 gaps, suite 1274 passed, ReDoS sweep 169 patterns.
2026-11-01 17:06:44 +01:00

367 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# llm-ingestion-guard
Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure.
![Version](https://img.shields.io/badge/version-1.9.0-blue)
![Status](https://img.shields.io/badge/status-stable-brightgreen)
![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![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:** `v1.9.0`. 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 exported
Python surface is now frozen under semver: nothing exported is removed, renamed or
given a different meaning without a `2.0.0`. **Detection behaviour is not frozen** —
severities, thresholds and lexicon entries are calibration and move in `1.x`. There
are real limitations, stated plainly below; read them.
## Table of Contents
- [Install](#install)
- [Quickstart — the two bookends](#quickstart--the-two-bookends)
- [OKF / LLM-wiki support (shipped)](#okf--llm-wiki-support-shipped)
- [What it protects against](#what-it-protects-against)
- [The reusable contract (adopt-this checklist)](#the-reusable-contract-adopt-this-checklist)
- [Known limitations](#known-limitations)
- [Non-goals](#non-goals)
- [Design & threat model](#design--threat-model)
- [License](#license)
## 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@v1.9.0"
```
Or take a version range from the Forgejo package index, which carries `1.9.0`, `1.8.0`, `1.7.0`, `1.6.0`,
`1.5.0`, `1.4.2`, `1.4.1` and `1.1.0` (`1.2.0`–`1.4.0` were never published there):
```bash
pip install --index-url https://git.fromaitochitta.com/api/packages/open/pypi/simple/ "llm-ingestion-guard>=1.9.0,<2"
```
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.
**Verify it yourself — nothing runs the suite automatically.** There is no CI
runner on the forge, so the test count is not something a badge can honestly
assert. From a clean clone:
```bash
pip install -e ".[dev]" && pytest # the whole suite
```
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 `>=1.0,<2.0`. Range pinning — and therefore automatic pickup of patch
releases — comes from the Forgejo package index above, the durable channel
from `1.4.1` (the first `1.x` patch release) on. The distribution name
(`llm-ingestion-guard`) and the version scheme are the same on both channels,
so a tag pin written earlier keeps its 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.
**New in `v0.4.0`, and the reason for the major-line bump.** `prepare_input` fails
**closed** on size too: above `MAX_INPUT_CHARS` (1 000 000) it raises
`OversizeInputError`, a `ContractViolation` subclass, rather than returning a
half-sanitized document. If you call `sanitize` / `fence` / `neutralize` on
documents that large, this is the upgrade that needs a `try` — everything else in
0.4.0 is additive. The scanners bound their work differently: they read a prefix
and flag, which costs only detection in the tail. A transform returns *content*,
where the same move would either drop your data silently or hand back an
untransformed tail — the exact place an attacker would put the payload. Catch it
where you catch your other ingest refusals; the exception carries sizes and the
refusing surface, never any of the input.
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). A mapping has four carriers: the flow mapping as a value
(`generated: { by: x, at: y }`) or as a block-list item, a flow sequence of flow
mappings (`sources: [{ id: a, resource: x }]`), and a block sequence of block
mappings (SPEC §5.1's own form). Every carrier is admitted key-by-key against a
nine-name allowlist (`by`, `at`, `from`, `to`, `id`, `title`, `author`,
`usage_count`, `last_modified`) with plain-scalar leaves only; `resource` and
`usage_window` are admitted *inside a `sources` entry only*, so an `executor` or
`attester` `resource` is refused through every carrier. A key off that list, a
nested collection or a duplicate key is refused; a top-level block mapping and
the dotted and inline-colon routes to a mapping still raise. A
*sequence* value has two carriers — the block list, and (as of `1.4.0`) the flow
sequence `tags: [a, b, c]`, which is SPEC §4.1's own skeleton — whose elements
are either all plain scalars or all flow mappings, never a mix. A scalar element
carrying any of `{ } [ ] , " '` is refused rather than guessed at, and so is a
`#` or `:` where YAML reads it (a `#` opening the element or following
whitespace, a `:` opening or ending it or preceding whitespace); since `1.4.1`
`[/docs/a#anchor, vscode://x, https://e.com:8443/a]` parses, as it does in
YAML. See
[LIMITATIONS](docs/LIMITATIONS.md) for what that admits and what it still walls
off; **`resource` https-allowlist** (hard-rejects
`data:`/`javascript:`/`file:` before commit — a reject-gate, not defang — on the
top-level `resource` only: a `sources[].resource` is never URL-validated, because
SPEC §5.1 allows bundle-relative paths there, so a consumer that follows it calls
`validate_resource_url` itself);
**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 Top 10 for LLM Applications **2026**
anchor. Numbers carry the year, OWASP's own form, because the 2026 edition
renumbered the list; a class no entry describes carries no number. Every row is
driven by a **live payload** in the coverage matrix — run it to watch all 151 pass
in your own environment:
```bash
python -m llm_ingestion_guard.coverage # 145/145 classes; exit 0 = all as documented
```
| Anchor | Attack classes it stops (representative) |
|---|---|
| **LLM01:2026 · prompt injection** | 83 instruction-override lexicon classes (ignore / forget / disregard / suspend-constraints, role-play, jailbreak, …); hidden carriers — every invisible codepoint in Unicode 18.0 (zero-width, BIDI override, Unicode-tag, variation-selector and Sneaky-Bits smuggling, private use; legitimate emoji, CJK, Arabic-script, Indic and right-to-left uses exempt on context), HTML-comment, `data:` URI — stripped on input **and** re-checked at the persist gate (a comment the model leaves in its artifact is reported unless it sits in code); text hidden by styling — a font too small to read, or text coloured like its own background; high-entropy base64/hex blobs; an injection hidden inside a base64 blob (decoded, then re-scanned); an instruction in plain prose whose paragraph combines two of: it speaks to the model, it is meant to act later, it writes to the model's memory, it hides from the user |
| **LLM02:2026 · sensitive-info disclosure** | Secret egress in output — AWS keys, tokens, private keys, the credential set; a base64- or hex-*wrapped* secret (decoded → `decoded:egress:*`) |
| **LLM10:2026 · 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; a run of images whose *order* carries a value while each URL is ordinary (CamoLeak) |
| **LLM03:2026 · excessive agency** | A tool surface on the quarantined transform; credential use beyond the per-stage allowlist; capability isolation (`scoped_env`) |
| **Fail-secure** *(the guard's own failure semantics)* | 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); non-`https` / active-scheme `resource` URLs and the `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 six classes it deliberately does **not** stop are called
out in [Known limitations](#known-limitations).
### The persisted store: OWASP ASI06 and MITRE ATLAS
The store this guard writes to is what the
[OWASP Top 10 for Agentic Applications for 2026](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/)
calls **ASI06 · Memory & Context Poisoning**: persistent corruption of agent memory,
RAG stores or contextual knowledge that keeps acting long after the write. The rows
below map it, and the [MITRE ATLAS v2026.10](https://github.com/mitre-atlas/atlas-data/releases/tag/v2026.10)
techniques that reach a write-time pipeline, onto what the guard does. The right-hand
column is the part it does **not** stop. Each one was measured or is a documented gap.
| Catalog entry | What the guard stops at the write | What it does not stop |
|---|---|---|
| **ASI06 · Memory & Context Poisoning** | Instruction-carrying and hidden-carrier content, before it is persisted (scan-before-persist); a trust tier from `Origin` × `Channel`; one provenance line per concept in `log.md` | A false claim in clean prose — semantic poisoning, the highest-impact gap |
| **AML.T0070 · RAG Poisoning** | Prompt injections and hidden carriers in a document headed for the index | The "false or misleading information" half — same gap |
| **AML.T0080.000 · AI Agent Context Poisoning: Memory** | A memory entry that carries instructions the lexicon or a carrier check sees; a plain-prose instruction to save to memory on a later trigger, or to keep a standing order from the user | An entry that only steers in plain prose, with no address, deferral, memory write or concealment to read |
| **AML.T0143 · LLM Prompt Propagation** | Instructions that the lexicon or a carrier check sees are caught at the output gate, so they never reach the store that would pass them on | Propagation over a channel the guard does not sit on |
| **AML.T0051.001 · LLM Prompt Injection: Indirect** | The **LLM01:2026** row above | A plain-prose task with no marker (a documented gap), a paraphrase outside the word lists, or the same instruction in another language; the tool-less transform carries those cases |
| **AML.T0068 · LLM Prompt Obfuscation** | Invisible characters, HTML comments, a font too small to read, text coloured like the background declared on the same element, base64 and rot13 (decoded, then re-scanned) | White text whose background sits on a parent element or in a stylesheet: a plain-prose instruction hidden that way gives 0 findings |
| **AML.T0077 · LLM Response Rendering** | The **LLM10:2026** row above: markdown-image auto-fetch, links, raw active HTML, a run of images whose order spells a value | Pure beaconing to a bare path on a hostile host (a documented gap); an image run whose symbols are words rather than short tokens |
| **AML.T0066 · Retrieval Content Crafting** | Only the crafted content that also carries an injection | The crafted content itself — semantic gap |
**Every catalog the guard cites is on its newest edition, with the date it was
last checked and a link.** The full list is the register in
[`docs/threat-2026/catalogs.json`](docs/threat-2026/catalogs.json), and
[`tests/test_catalog_register.py`](tests/test_catalog_register.py) fails on an
entry without a date or a link. The main ones, checked **2026-10-31**:
| Catalog | Edition | Published |
|---|---|---|
| [OWASP Top 10 for LLM Applications](https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/) | 2026 | 2026-08-04 |
| [OWASP Top 10 for Agentic Applications](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/) | for 2026 | 2025-12-09 |
| [MITRE ATLAS](https://github.com/mitre-atlas/atlas-data/releases/tag/v2026.10) | v2026.10 | 2026-10-28 |
| [OWASP RAG Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/RAG_Security_Cheat_Sheet.html) | living, last changed 2026-10-04 | 2026-05-06 |
| [The Unicode Standard](https://www.unicode.org/versions/Unicode18.0.0/) | 18.0.0 | 2026-09-16 |
| [CVE-2025-32711](https://nvd.nist.gov/vuln/detail/CVE-2025-32711) (EchoLeak) | NVD record, modified 2026-06-17 | 2025-06-11 |
## 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 (every invisible codepoint
not exempt on its context, 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.
## Known 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.
- **Five documented gaps** the coverage matrix keeps honest: semantic poisoning,
an unmarked plain-prose task, trusted-prose lone-HIGH, pure beaconing, and short
opaque URL segments. Four of them carry a measured reason: the cheapest rule that
was tried, and the false alarms it cost. Two of the seven frozen on 2026-11-01 are
closed: lexicon dedup (`count=1`) in 1.9.0 and hex-wrapped secret egress in 1.10.0.
- **The upload door is a review queue, not an auto-persist path — measured.** On
three benign document populations, `PRESET_USER_UPLOAD` disposed **98 of 185**
(53.0%), **88 of 547** (16.1%) and **133 of 389** (34.2%) documents to something
other than WARN. Technical documentation is the expensive case: it is dense in the
exact constructs the gate grades. Budget human review, or run a source you actually
trust as trusted. Re-run it yourself with [`docs/fp-sweep.py`](docs/fp-sweep.py).
Those three are the published pre-0.6.0 numbers; the raw-HTML narrowing in 0.6.0
moves them to **108**, **98** and **88** measured against current corpus state —
two of the three corpora are living, so the cells are not rewritten in place.
Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py).
**Full list — 51 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.
## Non-goals
Embedding/vector-layer defenses (OWASP LLM09:2026, 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
Top 10 for LLM Applications 2026 (LLM01:2026, LLM02:2026, LLM03:2026, LLM05:2026,
LLM10:2026 strongest; LLM06:2026 self-safety; LLM09:2026 boundary); ASI06 from the
OWASP Top 10 for Agentic Applications for 2026; MITRE ATLAS v2026.10 (the
[mapping above](#the-persisted-store-owasp-asi06-and-mitre-atlas)); the
[OWASP RAG Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/RAG_Security_Cheat_Sheet.html)
(checked 2026-10-31); [PoisonedRAG](https://arxiv.org/abs/2402.07867);
guardrail evasion ([arXiv 2504.11168](https://arxiv.org/abs/2504.11168)); EchoLeak
([CVE-2025-32711](https://nvd.nist.gov/vuln/detail/CVE-2025-32711)). Edition, check
date and link for every source: [the register](docs/threat-2026/catalogs.json).
## License
MIT — see [LICENSE](LICENSE).