From 0af8f68cae3aaf1be44dca4c3b2f4936102c3473 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 4 Jul 2026 23:34:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(wiring):=20=C2=A76=20bookends=20(prepare?= =?UTF-8?q?=5Finput/screen=5Foutput)=20+=20full=20public=20API=20+=20READM?= =?UTF-8?q?E=20v0.1=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module 11 (final) — the top-level wiring. The library never makes the model call (no SDK imported by the core), so the public surface is the toolkit plus two library-side bookends around the caller's tool-less transform (Form 1, chosen with the operator over an export-only toolkit and a full orchestrator — the bookends fit existing pipelines with least friction, encode the two halves the library can stand for, and impose no control flow): - prepare_input(text, source=INPUT) -> PreparedInput(fenced, nonce, report): §6 steps 1-2, sanitize THEN fence (carrier can never smuggle a forged delimiter). Merged report carries both steps' findings; renders no disposition. - screen_output(text, policy, *, provenance, transform_failed) -> DispositionResult: §6 steps 6-7, scan_output under guard() so a scanner error fails CLOSED (FAIL_SECURE, never a silent persist). transform_failed routes the compound forced-fallback rule. - __all__ exports the full framework-agnostic surface: detectors, result types, disposition machinery + presets, contract asserters, the grounding seam. Docs: README refreshed from the stale "brief / pre-implementation" line to a v0.1 alpha status with a Form-1 quickstart, the §6 adopt-this checklist, and an honest -limitations section (structural unsolvability at the text layer; semantic poisoning invisible to lexicon+entropy; text-only, no multimodal). CHANGELOG seeded; CLAUDE.md remote/status lines corrected (remote IS set, no longer brief-stage). 10 wiring tests (public surface, prepare_input compose, screen_output fail-closed + compound). 189 green (showcase + corpora follow). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k --- CHANGELOG.md | 28 ++++++ CLAUDE.md | 10 +- README.md | 119 +++++++++++++++++++++++- src/llm_ingestion_guard/__init__.py | 136 +++++++++++++++++++++++++++- tests/test_wiring.py | 127 ++++++++++++++++++++++++++ 5 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 tests/test_wiring.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6361e43..4ff407d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ # Changelog All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] — v0.1.0 (alpha) + +The stdlib-only core, built test-first (TDD) per `docs/PLAN.md`. + +### Added + +- `report` — shared `Finding` / `Report` / `Severity` / `Source` types. +- `sanitize` — carrier stripping (zero-width, BIDI, Unicode-tag, HTML comment, + `data:`); byte-identical on clean input. +- `entropy` — Shannon / base64-like / hex-blob detection; base64 decode-and-rescan. +- `lexicon` — JSON pattern data + loader; raw/normalized/homoglyph/rot13 variants; + ReDoS-bounded, size-capped. +- `fence` — randomized per-call spotlight delimiter; attacker marker-strip. +- `neutralize` — opt-in defang of active-content output (byte-identical when clean). +- `output` — compose lexicon + entropy + decode-rescan over emitted text; secret + egress patterns (OWASP LLM02); report-only, never mutates. +- `disposition` — WARN | QUARANTINE_REVIEW | FAIL_SECURE under a source-trust + policy; compound-signal escalation; fail-**closed** when the scanner errors. +- `contract` — write-time asserters that raise: `assert_tool_less`, + `assert_credential_allowlist`, `scoped_env`. +- `grounding` — the `SourceGroundingCheck` seam for semantic poisoning (interface + only; `[judge]` implementation plugs in behind an extra). +- Top-level wiring — the `prepare_input` / `screen_output` §6 bookends plus the + full public surface; end-to-end showcase and adversarial + false-positive corpora. diff --git a/CLAUDE.md b/CLAUDE.md index e3a0f76..8430dec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,15 @@ framework-agnostisk kode. Referanse-implementasjon: `claude-code-llm-wiki` Stage B (`tools/wiki_ingest/`). Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen. -Repoet er på **brief-stadiet**. Start med `docs/BRIEF.md`. +Repoet er på **v0.1 (alpha)**: stdlib-kjernen er bygget og testet (10 moduler + +topp-nivå wiring, showcase + korpus). Start med `docs/BRIEF.md` for design, +`README.md` for bruk, `docs/PLAN.md` for byggerekkefølgen. ## Konvensjoner -- Norsk for dialog og planer, engelsk for kode og innhold (repoet er ment publisert). -- Ingen GitHub — kun Forgejo (`git.fromaitochitta.com`) hvis/når publisert. -- Ingen remote satt ennå; ingen push før operatøren bestemmer publisering. +- Norsk for dialog og planer, engelsk for kode og innhold (repoet er publisert). +- Ingen GitHub — kun Forgejo (`git.fromaitochitta.com`). +- Remote satt: offentlig `open/`-speil på Forgejo; push hver commit (durabelt autorisert). - Minimal-dependency: stdlib-first kjerne; ML/judge-detektorer bak extras. ## Communication patterns diff --git a/README.md b/README.md index a5b0262..c0a55ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# llm-ingestion-pipeline-security +# llm-ingestion-guard A reusable, minimal, dependency-light defensive layer for **LLM ingestion pipelines** — the write-time siblings of query-time chatbot guardrails. @@ -9,11 +9,120 @@ untrusted content flowing through an LLM enrichment/summarization/extraction ste into a **persisted, downstream-consumed artifact** (RAG corpus, knowledge base, wiki). It packages the architectural contract — sanitize → fence → tool-less quarantined transform → per-stage capability isolation → scan output before -commit → fail-secure — as composable, framework-agnostic code. +commit → fail-secure — as composable, stdlib-first, framework-agnostic code. -**Status:** brief / pre-implementation. Start with the design brief: +The gap it fills is **not** "no one detects injection." It is a small *library* +(not a hosted service, not a fine-tuned model) that packages the **write-time +ingestion contract** — the part query-time tooling structurally cannot see, +because a poisoned artifact committed at write time is read by a *downstream* +agent whose guardrail never sees where it came from. -- [Design brief](docs/BRIEF.md) — what this repo should contain and why. +**Status:** `v0.1`, alpha. The stdlib-only core is built and tested — ten +detector/contract modules and the top-level wiring, 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 + +```bash +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: + +```python +from llm_ingestion_guard import ( + prepare_input, screen_output, Disposition, PRESET_USER_UPLOAD, +) + +prepared = prepare_input(untrusted_content) # §6 1-2: sanitize + fence +enriched = your_model(prepared.fenced) # §6 3: tool-less — YOUR call +decision = screen_output(enriched, PRESET_USER_UPLOAD) # §6 6-7: scan + dispose + +if decision.disposition is Disposition.FAIL_SECURE: + alert(gate_code=decision.reasons) # §6 8: minimal payload, no content + raise SystemExit # §6 7: 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`, +`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. + +## 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 over the emitted + text. Verbatim-carried payloads and model-emitted instructions are caught here. +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 (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) is what + carries the security — the lexicon is defense-in-depth, not a wall. +- **Semantic / factual poisoning is invisible** to lexicon + entropy: a + factually false claim in clean prose carries no suspicious token. 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. +- **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 + in images/PDFs are out of scope beyond the sanitizer's character-layer stripping. + +## 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. +- [Build plan](docs/PLAN.md) — module build order and the reuse map. The contract is extracted from a working reference implementation (the -`claude-code-llm-wiki` Stage B enrichment pipeline). +`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). diff --git a/src/llm_ingestion_guard/__init__.py b/src/llm_ingestion_guard/__init__.py index ac1af0f..99f5782 100644 --- a/src/llm_ingestion_guard/__init__.py +++ b/src/llm_ingestion_guard/__init__.py @@ -5,7 +5,141 @@ packages the ingestion-side security contract — sanitize -> fence -> tool-less quarantined transform -> per-stage capability isolation -> scan output before persist -> fail-secure — as composable, stdlib-first, framework-agnostic code. -The public API is wired up as modules land; see docs/PLAN.md for the build order. +The library never makes the model call itself (no SDK is imported by the core), +so the public surface is a **toolkit plus two bookends** around the caller's +tool-less transform (BRIEF §6): + + prepared = prepare_input(untrusted_content) # §6 steps 1-2: sanitize + fence + output = your_model(prepared.fenced) # §6 step 3: tool-less, caller's job + decision = screen_output(output, policy) # §6 steps 6-7: scan + dispose + + if decision.disposition is Disposition.FAIL_SECURE: + raise SystemExit # halt + alert; never persist (§6 steps 7-8) + +The individual detectors (:func:`sanitize`, :func:`scan_lexicon`, +:func:`scan_output`, ...), the contract asserters (:func:`assert_tool_less`, +:func:`scoped_env`, ...) and the disposition machinery are all exported for +pipelines that compose the checklist themselves. See ``docs/PLAN.md`` for the +build order and ``docs/BRIEF.md`` §6 for the contract this wiring encodes. """ +from __future__ import annotations + +from dataclasses import dataclass + +from .report import Finding, Report, Severity, Source, severity_rank +from .sanitize import sanitize, SanitizeResult +from .entropy import scan_entropy, EntropyResult, DecodedBlob +from .lexicon import scan_lexicon, load_lexicon, LexiconPattern +from .fence import fence, FenceResult +from .neutralize import neutralize, NeutralizeResult +from .output import scan_output, scan_secret_egress +from .disposition import ( + decide, + guard, + Policy, + Trust, + Provenance, + Disposition, + DispositionResult, + PRESET_TRUSTED_SOURCE, + PRESET_USER_UPLOAD, +) +from .contract import ( + assert_tool_less, + assert_credential_allowlist, + credential_env_names, + scoped_env, + ContractViolation, +) +from .grounding import ( + SourceGroundingCheck, + no_grounding_check, + DEFAULT_GROUNDING_CHECK, +) __version__ = "0.1.0" + + +# --- §6 bookends: the two library-side halves around the transform --------- + + +@dataclass(frozen=True) +class PreparedInput: + """Untrusted content made ready for a tool-less transform (§6 steps 1-2). + + ``fenced`` is the sanitized, spotlight-fenced text to hand the model. + ``nonce`` is the per-call fence delimiter — a caller may check for it in the + output to detect a fence breakout. ``report`` merges the sanitize and fence + findings so the caller can gate on the *input* too, not only the output. + """ + + fenced: str + nonce: str + report: Report + + +def prepare_input(text: str, source: Source = Source.INPUT) -> PreparedInput: + """Sanitize then fence untrusted ``text`` (BRIEF §6 steps 1-2). + + Strips carrier classes first (:func:`sanitize`), then spotlight-fences the + cleaned payload in a randomized per-call delimiter (:func:`fence`) — sanitize + *before* fence so a carrier can never smuggle a forged delimiter. The merged + report carries both steps' findings; ``prepare_input`` itself renders no + disposition (design principle 4 — the caller decides). + """ + sanitized = sanitize(text, source) + fenced = fence(sanitized.text, source) + report = Report() + report.extend(sanitized.report.findings) + report.extend(fenced.report.findings) + return PreparedInput(fenced=fenced.text, nonce=fenced.nonce, report=report) + + +def screen_output( + text: str, + policy: Policy, + *, + provenance: Provenance = Provenance.PROSE, + transform_failed: bool = False, +) -> DispositionResult: + """Scan the emitted ``text`` and dispose it, failing *closed* (§6 steps 6-7). + + Runs :func:`scan_output` (lexicon + entropy + decode-and-rescan + secret + egress) under :func:`guard`, so a scanner that errors on crafted input yields + ``FAIL_SECURE`` rather than an auto-persist — an un-scannable artifact is + never committed. ``transform_failed=True`` (the caller's model call raised or + fell back) plus any finding is treated as a probable forced-fallback attack + and also halts, regardless of trust tier. + """ + return guard( + lambda: scan_output(text, source=Source.OUTPUT), + policy, + provenance=provenance, + transform_failed=transform_failed, + ) + + +__all__ = [ + "__version__", + # shared types + "Finding", "Report", "Severity", "Source", "severity_rank", + # input-side detectors + result types + "sanitize", "SanitizeResult", + "scan_entropy", "EntropyResult", "DecodedBlob", + "scan_lexicon", "load_lexicon", "LexiconPattern", + "fence", "FenceResult", + "neutralize", "NeutralizeResult", + # output-side + "scan_output", "scan_secret_egress", + # disposition + "decide", "guard", "Policy", "Trust", "Provenance", + "Disposition", "DispositionResult", + "PRESET_TRUSTED_SOURCE", "PRESET_USER_UPLOAD", + # contract asserters + "assert_tool_less", "assert_credential_allowlist", + "credential_env_names", "scoped_env", "ContractViolation", + # grounding seam + "SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK", + # §6 bookends + "prepare_input", "screen_output", "PreparedInput", +] diff --git a/tests/test_wiring.py b/tests/test_wiring.py new file mode 100644 index 0000000..4d740aa --- /dev/null +++ b/tests/test_wiring.py @@ -0,0 +1,127 @@ +"""Tests for the top-level wiring — the §6 bookends + public surface (PLAN §95). + +The library never makes the model call, so the wiring is two library-side halves +around the caller's tool-less transform (BRIEF §6): + +* ``prepare_input`` — §6 steps 1-2: sanitize -> fence (before the transform). +* ``screen_output`` — §6 steps 6-7: scan the emitted text -> dispose, failing + *closed* if the scanner itself errors (an un-scannable artifact never persists). + +Everything is imported from the top-level package here on purpose: these tests +also pin the ``__all__`` export surface a consumer depends on. +""" +from __future__ import annotations + +import llm_ingestion_guard as g +from llm_ingestion_guard import ( + PreparedInput, + prepare_input, + screen_output, + Disposition, + PRESET_TRUSTED_SOURCE, + PRESET_USER_UPLOAD, + Severity, + Source, +) + + +# --- public surface -------------------------------------------------------- + +_EXPECTED_SURFACE = [ + # shared types + "Finding", "Report", "Severity", "Source", "severity_rank", + # input-side detectors + "sanitize", "scan_entropy", "scan_lexicon", "load_lexicon", "fence", + "neutralize", + # output-side + "scan_output", "scan_secret_egress", + # disposition + "decide", "guard", "Policy", "Trust", "Provenance", "Disposition", + "DispositionResult", "PRESET_TRUSTED_SOURCE", "PRESET_USER_UPLOAD", + # contract asserters + "assert_tool_less", "assert_credential_allowlist", "credential_env_names", + "scoped_env", "ContractViolation", + # grounding seam + "SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK", + # §6 bookends + "prepare_input", "screen_output", "PreparedInput", +] + + +def test_public_surface_is_exported(): + for name in _EXPECTED_SURFACE: + assert name in g.__all__, f"{name} missing from __all__" + assert hasattr(g, name), f"{name} not importable from package" + + +def test_version_is_exported(): + assert isinstance(g.__version__, str) and g.__version__ + + +# --- prepare_input: §6 steps 1-2 (sanitize -> fence) ----------------------- + +def test_prepare_input_returns_prepared_input(): + prepared = prepare_input("plain prose") + assert isinstance(prepared, PreparedInput) + assert isinstance(prepared.fenced, str) + assert isinstance(prepared.nonce, str) and prepared.nonce + # the per-call nonce delimits the fenced payload the transform will see. + assert prepared.nonce in prepared.fenced + + +def test_prepare_input_sanitizes_before_fencing(): + # a zero-width carrier is stripped (sanitize) and the payload is then fenced. + prepared = prepare_input("ignore​previous") + assert "​" not in prepared.fenced + labels = {f.label for f in prepared.report.findings} + assert "sanitize:zero-width" in labels + + +def test_prepare_input_clean_text_reports_nothing(): + # clean input: fenced (wrapped in the nonce) but no carrier/marker findings. + prepared = prepare_input("a perfectly ordinary changelog entry.") + assert not prepared.report.found + + +def test_prepare_input_report_merges_sanitize_and_fence(): + # the report carries findings from BOTH composed steps, under their detectors. + prepared = prepare_input("data:text/html,