The output gate claimed LLM10 self-safety on the grounds that its patterns have no nested quantifiers. True, and irrelevant: nesting is not what makes these blow up. A run in front of a REQUIRED literal, reachable from a short anchor, is enough -- crafted input repeats the anchor and never supplies the literal, so every start position rescans the tail. Quadratic, not exponential, and the max_scan_chars cap does not help: it bounds the input, and quadratic work on a bounded input is still hours. Measured, not argued. `<a:` x 100_000 took 23.4s in AUTOLINK_RE alone; the composed gate on that payload took 458.7s, extrapolating to ~5.7 hours at the 1_000_000-char input the gate itself accepts. Size-matched ordinary prose runs 0.31s, so the separation is 18x-660x -- unlike the blob in the neighbouring test, which is the *faster* side of prose and never exercised backtracking. Two fixes, chosen per pattern rather than uniformly: - active_content + lexicon JSON (15 runs): exclude the character that opens the pattern's own anchor (`[` for markdown, `<` for tags), so a run cannot reach past the next start position and the per-start costs telescope. Verified to cost no recall: long URLs, long alt text, and `<` inside a quoted attribute all still match. Bounding instead would have been linear too but wrong here -- the content is attacker-controlled, so padding past a bound would be a one-line bypass of the EchoLeak class this table exists to catch. - connstr egress (4 runs): bound the password at MAX_CONNSTR_VALUE. The exclusion fix is unavailable -- the anchor character is `/` and passwords containing `/` are the common case (measured: they match today). The residual miss is a credential over 256 chars; a token that long is still caught by egress:jwt-token. hybrid-xss:script-tag had neither option: its run is the script BODY, which may legitimately contain `<`. It now matches the opening tag and drops the `</script>` requirement. That also closes a fail-open -- `<script>alert(1)` unclosed was silently missed -- at the cost of flagging prose that merely mentions `<script>`, now documented. Found by the composed-gate test staying red after every individual scanner was already linear: the lexicon's six html-obfuscation patterns were the remaining 813x. A per-scanner test alone would have shipped that. 662 passed (was 642), and faster than before the fix.
22 KiB
Implementation Plan — llm-ingestion-guard v1
Derived from docs/BRIEF.md plus a 2026-07-04 research pass (competitive landscape,
novelty/gap refutation, security-coverage gaps). This plan folds the verified gaps
into the v1 scope. The BRIEF is the design rationale; this is the actionable build.
Resolved decisions (2026-07-04)
- Name: distribution
llm-ingestion-guard, importllm_ingestion_guard. Repo directory staysllm-ingestion-pipeline-security. - License: MIT.
- Lexicon storage: shared JSON data + thin Python loader (single source of truth, polyglot-ready for a later TS port).
- Publish target: Forgejo
open/only (public). No PyPI, no GitHub. Install viapip install git+ssh://…/llm-ingestion-guard.git. - Language: Python-first, stdlib-only core; ML/judge detectors behind extras
(
[ml],[judge]), never in the core path.
Positioning — A: "Guard the artifact"
Query-time guardrails guard the answer.
llm-ingestion-guardguards the artifact.
Lead with the contract + placement (write-path, pre-persist) + failure-semantics (fail-secure toward the artifact, not fail-open toward a user). Detection is the weakest, most-evadable layer — defense-in-depth, not the pitch.
Defensible claim, every qualifier load-bearing: a dependency-light,
framework-agnostic library that packages the full write-time
injection-containment contract (quarantine → per-stage capability isolation →
scan-before-persist → fail-secure disposition) as composable code — the part
query-time tooling and single-stage detectors leave to the integrator. Not "the
first" (an unverifiable temporal claim); the load-bearing qualifier is the full
four-part contract, verified against the 2026-07-15 survey (docs/BRIEF.md §11).
Cite OWASP LLM08:2025 / RAG Security Cheat Sheet for legitimacy; reference Dual-LLM
(Willison 2023) and CaMeL (DeepMind 2025) as architecture lineage — inspiration,
not equivalence.
Claims we will NOT make (verified overclaim risks)
- NOT "prevents RAG/knowledge poisoning" — cannot catch factual poisoning (PoisonedRAG: plausible false facts, no markers, low entropy).
- NOT "blocks prompt injection" — pattern/entropy detection is bypassable (arXiv 2504.11168, up to 100% evasion). Detection is a signal feeding disposition, never a sole gate.
- NOT "invented" tool-less quarantine / capability isolation — prior art (Dual-LLM, CaMeL).
- NOT "the only" ingestion-security tool — PII-scoped libs (Philter, Presidio) and services (AWS, HiddenLayer) exist; we are the minimal-dep library for the injection-containment contract.
- Positioned as complementary to query-time guardrails (LLM Guard, Lakera, LlamaFirewall), not a replacement.
Module set (v1)
Core (BRIEF §5): report, sanitize, fence, lexicon, entropy, contract,
output, disposition.
Gap additions folded into v1 (from the security-coverage research):
neutralize(new) — opt-in pure defang helpers for model OUTPUT: markdown images, autolinks,[ref]links,data:/external URIs, raw HTML. Mutation is opt-in and kept separate from the report-only gate, so design principle 3 (pure detection) and 4 (disposition belongs to the caller) hold. Closes EchoLeak-class (CVE-2025-32711) persisted zero-click exfil — these are neither injection strings nor high-entropy, so lexicon+entropy alone miss them.outputextended — secret/PII egress patterns (OWASP LLM02); decode-and-rescan (run the lexicon over decoded base64/hex, not just flag blob presence).fencehardened — randomized, unspoofable per-call delimiter; strip attacker fence markers from the payload first.- Scanner self-safety (OWASP LLM10) — input-size cap, ReDoS-safe patterns (bounded quantifiers + per-scan guard), decompression guard. A scanner that hangs on crafted input is the DoS.
grounding(interface now, impl later) — aSourceGroundingCheckprotocol in core; ML/judge implementation behind the[judge]extra. The only structural handle on semantic poisoning that lexicon+entropy cannot see. Ship the seam + honest scope note.- Chunk-aware / sliding-window scan — option for split-payload evasion across chunk boundaries.
dispositionpresets — named source-trust tiers, including a high-untrust user-upload / open-source preset: QUARANTINE_REVIEW as the default and hard-fail on CRITICAL, not WARN. An automatic, unattended inbox that ingests arbitrary user uploads is the canonical high-untrust consumer (§4.7) — over-blocking one upload is far cheaper than persisting a poisoned one.
Build order (TDD — a failing test FIRST for each)
report— findings / Report dataclass (shared return type)sanitize— carrier stripping (zero-width, BIDI, Unicode-tag, HTML comment,data:); byte-identical invariant on clean input, removes onlyentropy— shannon / base64-like / hex-blob; decode-and-rescan supportlexicon— JSON data + loader +scan; ReDoS-safe; size cap; port from thellm-securityJS seed (CRITICAL/HIGH/MEDIUM/HYBRID + normalization/homoglyph/rot13/ unicode-tag)fence— randomized delimiter; marker-stripneutralize— opt-in defang helpersoutput— compose lexicon+entropy over output; PII/secret egress; decode-and-rescandisposition— WARN | QUARANTINE_REVIEW | FAIL_SECURE; source-trust + intra-document provenance tiering (§4.7); compound-signal fail-secure (§4.6); fail-closed when the scanner itself errorscontract— tool-less assert; per-stage credential allowlist; env-scoping helpersgrounding— protocol/interface only in core- Top-level
__init__wiring the §6 checklist;pyproject.toml;LICENSE; README (positioning + honest limitations); adversarial + false-positive corpora; the end-to-end showcase pipeline test (see Test strategy — built LAST)
Reuse map — llm-security v7.8.0 (MIT, same author)
Maximal reuse: most detection logic is a JS→Python port, not new code.
| Our module | Seed in llm-security |
Notes |
|---|---|---|
lexicon |
scanners/lib/injection-patterns.mjs |
CRITICAL/HIGH/MEDIUM/HYBRID tables + scanForInjection (variant-set: raw / normalized / homoglyph-folded / rot13, dedup by label) + checkCognitiveLoadTrap. The load-bearing port. |
sanitize |
scanners/unicode-scanner.mjs + string-utils.mjs |
zero-width / BIDI / Unicode-tag / homoglyph detection + stripBidiOverrides / decodeUnicodeTags / containsUnicodeTags. |
| normalization | string-utils.mjs normalizeForScan |
decode chain (unicode-tags → bidi → HTML-entities → unicode/hex/url escapes → base64), foldHomoglyphs, rot13, collapseLetterSpacing. Feeds decode-and-rescan. |
entropy |
scanners/entropy-scanner.mjs + string-utils.mjs |
shannonEntropy / isBase64Like / isHexBlob / tryDecodeBase64; length-calibrated thresholds; isFalsePositive suppression rules; DATA_URI_PREFIXES. |
report |
scanners/lib/output.mjs + severity.mjs |
finding / scannerResult envelope; SEVERITY; riskScore / verdict / riskBand / owaspCategorize. |
output secret/PII egress |
knowledge/secrets-patterns.md |
Ready secret regex set (AWS/Azure/GCP/GitHub/npm/OpenAI/Anthropic/PEM/DB-conn/password/JWT) + FP-suppression rules. Seeds the LLM02 egress gap directly. |
| tests (corpora) | examples/prompt-injection-showcase/payloads.json |
Labeled adversarial + false-positive corpus (category/severity/expected). Maps ~1:1 to §9. Plus knowledge/{attack-mutations,attack-scenarios,signatures}.json. |
| wiring reference | hooks/pre-prompt-inject-scan.mjs (input), post-mcp-verify.mjs (output gate) |
How the scan is invoked at input vs output — informs our output module + the §6 checklist. |
Port caveats (JS→Python, stdlib-only):
/…/i→re.I;Buffer.from(s,'base64')→base64.b64decode;codePointAt/fromCodePoint→ord/chr.TOKEN_RE = /[\p{L}\p{N}_]+/gu— Pythonrehas no\p{L}; use\wunderre.UNICODE(Py3 default) orstr.isalnum(). Noregexdependency.- ReDoS: some patterns (sub-agent spawn with nested
.*?) backtrack; Pythonrehas no timeout → enforce input-size cap + review/bound these patterns (the self-safety must-have). - Lexicon ships as JSON (regex source + label + severity), compiled by a thin Python loader — decouples data from engine for the future TS port.
Test strategy (BRIEF §9 + additions)
- Adversarial corpus — one payload per lexicon class incl. obfuscated + multi-language; measure and report recall.
- False-positive corpus — content legitimately discussing injection (security docs, changelogs); assert WARN-not-block default; hard-fail is an explicit opt-in.
- Sanitizer invariant — clean input returns byte-identical with an all-zero report.
- Contract asserters — a tool-carrying request and a credential-leaking stage env both raise; the happy path passes.
- Self-safety — pathological/ReDoS-prone and oversize input return within a bound,
never hang. Scope, measured 2026-07-31: the lexicon path is covered by
test_redos_pathological_subagent_input_returns_fast(crafted against a known-bad nested.*?). Theoutputpath is covered bytest_crafted_redos_payload_stays_bounded— 15 crafted payloads plus one through the composed gate. Writing them found the defect they were meant to rule out: 19 quadratic runs across 17 patterns inoutput(4),active_content(5 patterns / 7 runs) and the lexicon JSON (8), worst case ~5.7 hours at the 1_000_000-char cap the gate accepts. Note the shape, since it is not the textbook one: no nested quantifier is involved. A run in front of a required literal, reachable from a short anchor, is enough — crafted input repeats the anchor, never supplies the literal, and every start position rescans the tail. The earlier "output blob is slower than ordinary prose (0.93x/0.96x)" measurement stands and was never wrong; it simply measured throughput on a blob, which is a different question from what a crafted payload asks. - Neutralize — active-content output is defanged; clean output is byte-identical.
- End-to-end showcase (the FINAL deliverable, built last). One realistic
piece of ingested content that carries many vulnerabilities at once — visible
- zero-width/BIDI/Unicode-tag stego carriers, homoglyph + whole-string-base64 +
rot13-hidden injection, HTML-obfuscated and cognitive-load-buried payloads,
secret/PII egress in the enriched output, and active-content exfil links — run
through a mock ingestion pipeline (sanitize → lexicon+entropy+decode-rescan →
output gate → disposition). Assert every planted vulnerability is caught and
disposition fails secure. Doubles as the README's worked example. Inspiration:
llm-security/examples/{prompt-injection-showcase,poisoned-claude-md,lethal-trifecta-walkthrough,toxic-agent-demo}.
- zero-width/BIDI/Unicode-tag stego carriers, homoglyph + whole-string-base64 +
rot13-hidden injection, HTML-obfuscated and cognitive-load-buried payloads,
secret/PII egress in the enriched output, and active-content exfil links — run
through a mock ingestion pipeline (sanitize → lexicon+entropy+decode-rescan →
output gate → disposition). Assert every planted vulnerability is caught and
disposition fails secure. Doubles as the README's worked example. Inspiration:
- No network in any test.
README "Honest limitations" (shipped as a control)
Concede plainly (the concession prevents false assurance, which is itself a control): structural unsolvability at the text layer; adversarial-ML evasion survives normalization; tokenizer mismatch; semantic/factual poisoning invisible to lexicon+entropy; latent/dormant memory poisoning not judgeable at write-time; insider in-place edits; text-only (no multimodal).
Out-of-scope (documented boundary)
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
steganography; query-time/runtime guardrails; semantic factuality verification. The
contract tool-less assertion is the write-time analogue of runtime least-privilege.
Text-extraction boundary. For pipelines that ingest arbitrary user uploads (an upload
inbox that ingests automatically), the library stays text -> findings: it does not parse
files (no pypdf/python-docx/archive deps in the core). The pipeline extracts text
first, then scans the extracted text + the enriched output with provenance tagged as the
high-untrust upload tier. Binary/multimodal carrier detection (OCR-embedded instructions in
images/PDFs, stego) is out of scope beyond the sanitizer's character-layer stripping and
the self-safety size/decompression guard — and must be conceded in the honest-limitations
section, because a high-untrust, unattended pipeline is exactly where assuming
uncovered-coverage is most dangerous.
Threat-model anchors
OWASP LLM Top-10 2025: LLM01, LLM02, LLM04, LLM05, LLM06 (strongest coverage), LLM08 (boundary), LLM09, LLM10. Research anchors: PoisonedRAG (arXiv 2402.07867), guardrail evasion (2504.11168), EchoLeak (CVE-2025-32711), RAGShield (2604.00387), CaMeL (2503.18813), Dual-LLM (Willison), and the litellm supply-chain compromise (corroborates the minimal-dependency thesis).
v0.2+ stream sequencing (revised 2026-07-06)
v0.1.0 and v0.2.0 are tagged: the format-agnostic text core (modules 1–11,
5397ba1 / released df30c7b) and the OKF adapter (stream 1, released
542ac92). The forward order was re-sequenced on 2026-07-06 after a
ground-truth pass over the intended flagship consumer.
Finding that drove the change. The named flagship — portfolio-optimiser's
"OKF-upload-inbox" — does not exist as a seam. Both optimiser siblings are frozen
at their release milestone, carry their own OKF layer (navigate + materialize
from trusted manifests), receive no external bundles, and take no dependency on
this guard; the operator's own registered future work for them does not include
it. Wiring an immature guard into two mature, spec-frozen repos would complicate
them for a consumer that is not asking for it. Consumer integration is therefore
deferred until the guard is mature; consumers are informed at the operator's
timing, not pushed.
Revised streams:
- OKF v0.2 hardening — shipped (adapter + brief §8 tasks; T5b/B deferred to a consumer that owns corpus storage).
- Mature the guard here, keeping it Node-port-friendly — the near-term work.
The lexicon is already shared JSON (polyglot-ready); keep the
text -> findingssurface clean and free of Python-only cleverness in the OKF layer, so stream 3 is a translation, not a redesign. The centrepiece is the OKF inbox showcase (below): the guard demonstrating its own flagship use case end-to-end, in-repo. - Node/polyglot port — the strategic enabler of painless integration (many
OKF consumers are Node/JS; the lexicon seed was
.mjs). One polyglot repo over the shared JSON lexicon; never split §13.3. Its API/lexicon contract should be scan-informed (stream 4), not guessed. - Pre-adaptation scan (operator-timed) — at the operator's chosen point, scan every repo and plugin that uses or plans Google OKF, then adapt the guard's surface in advance so a later integration is painless. The scan is the input to the "painless" guarantee: it grounds both the Node port's contract and any consumer-specific seams before they are locked.
Re-sequenced toward v1.0 (Fable cross-model review, 2026-07-09)
An independent cross-model review re-sequenced the forward path. Stream 2 is extended with a set of review-injected hardening + coverage sessions (output-gate coverage, OKF adapter hardening, egress decode-rescan, calibration-threshold consolidation, docs/version-sync, and a novelty-claim verification) that land before a v1.0 freeze. The Node/TS port (stream 3) starts only after that freeze — porting a frozen, scan-clean surface, never a moving target — over the shared JSON lexicon (never split). Streams 4 + consumer integration are operator-timed "ambitious extensions", not part of the v1.0 sequence.
The session-by-session plan and the review record live in docs/ on the Forgejo
open/ mirror alongside the other design docs — docs/PLAN-v1.md (the v1.0 + Node
session plan) and docs/review-2026-07.md (the review) — open/ being the sole
sanctioned public surface (never GitHub).
The OKF inbox showcase (next concrete build, TDD)
The flagship artifact we hand a consumer later — an in-repo end-to-end
demonstration of the mode-b receive/quarantine gate, mirroring
tests/test_showcase.py but for a received external OKF bundle. It composes the
public okf surface exactly as an "upload inbox" consumer would, so it doubles as
the README's OKF worked example. Every test is authored by us — the point is to
prove intent, not to coincidentally pass.
- Composition (
tests/test_okf_showcase.py): an_inbox(bundle)helper callingokf.import_bundle(bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)and mapping the aggregate disposition —WARN→ ADMIT,QUARANTINE_REVIEW→ HOLD,FAIL_SECURE→ REJECT, any error → REJECT (fail-secure default). - One poisoned bundle planting one attack per OKF surface at once, each with a
label proving it was caught/rejected:
- body injection (T1 scan) and frontmatter
descriptioninjection (T1 whole-concept scan); - a non-
httpsresource:URL (T3 → FAIL_SECURE); - a path-traversal concept key
../x.md(T4 → FAIL_SECURE) and a reserved-name shadowindex.md/log.md(T4); - a dangerous frontmatter value (anchor / alias / explicit tag, T2 → FAIL_SECURE);
- a dangerous-scheme cross-link
[x](javascript:…)(T5a →links.rejected); - a dangling cross-link to an absent concept (§7.2 dormant signal →
links.dangling); - a carrier/obfuscation-hidden body injection (zero-width / homoglyph / rot13 /
whole-string base64) routed through
scan_concept.
- body injection (T1 scan) and frontmatter
- Assertions: aggregate disposition is FAIL_SECURE; every planted OKF
vulnerability appears in the per-concept rejects / findings / link graph; a
clean bundle admits (WARN, no rejects, no dangling);
BundleResult.log()renders one line per concept with rejects marked. Detach-proof: neuteringimport_bundleto always-admit makes the poison assertions fail. - Honest scope: demonstrates the structural + known-pattern OKF surface only — semantic/factual poisoning stays out (README honest-limits), consistent with the core showcase.
Realistic upload formats — the two-stage inbox (extract → materialize → guard)
A human inbox does not receive tidy {path: text} dicts; it receives the files
people actually drop: .txt, .md, Word .docx, Excel .xlsx, PowerPoint
.pptx, .pdf, .csv, whole folders, and .zip archives. The showcase
therefore has two stages, honouring the locked text-extraction boundary (§
"Text-extraction boundary"): the guard core never grows a file parser.
- Extract & materialize (the inbox front-end). Reads each dropped file, walks
folders, and safely unpacks archives; extracts text via format libraries
(
python-docx,openpyxl,python-pptx; stdlibzipfile/csv); materializes the result into an OKF bundle{concept_path: text}with provenance (origin=EXTERNAL, source filename + type). This stage owns the container and format threats. These libraries are showcase/dev-scoped only — never coredependencies(which stays[]). Promotion to an optional[extract]extra is a documented future option if a consumer wants turnkey extraction, not v1. - Guard (
import_bundle). Scans every extracted concept + the OKF structural gates → aggregate disposition. This stage owns the text/structural threats.
One representative planted vector per format (the point is where the payload hides — the place a human does not look):
| Input | Hidden vector planted | Caught at |
|---|---|---|
.txt |
raw injection + carrier (zero-width / homoglyph / rot13 / base64) | guard scan |
.md |
OKF frontmatter attack (T2) + body injection (T1) | guard |
.docx |
injection in a comment / hidden (vanish) run / core metadata property | guard scan of extracted text |
.xlsx |
formula injection (=cmd|'…', =HYPERLINK(…)) + hidden sheet / cell comment |
front-end + guard |
.pptx |
injection in speaker notes / off-slide text box / image alt-text | guard scan |
.pdf |
injection in extracted / white-on-white text | conceded — refused as an unsupported format, not extracted (see honest-scope below) |
.csv |
formula injection (=, +, -, @ lead) |
front-end + guard |
| folder | the OKF bundle-directory shape directly; one member path trips the path gate (T4) | guard |
.zip |
zip-slip entry ../../x.md (maps straight onto OKFPathError, T4) + zip-bomb (bounded by the size cap / a safe-extract limit, LLM10) + symlink entry |
front-end + guard |
Assertions: each planted vector is caught at the correct stage; the front-end refuses zip-slip / zip-bomb / oversize fail-fast; the guard rejects injection/carrier/frontmatter/resource/link/path; a clean file of every accepted format admits (WARN). Detach-proof at both stages.
Honest scope for uploads (must be conceded — README honest-limits). What
survives text extraction is out of scope: VBA/macros (.docm/.xlsm/.pptm),
OLE / embedded objects, image-embedded instructions that need OCR, font/render
steganography, and encrypted/password-protected files. The guard scans extracted
text; binary-layer carriers need a separate scanner. .pdf is conceded as a
format: a top-level .pdf drop is refused as unsupported rather than extracted,
since a PDF parser + reportlab (fixtures only) is disproportionate for a dev-scoped
showcase and its OCR / font-render stego carriers are out of scope regardless. A
high-untrust, unattended
inbox is exactly where assuming uncovered-coverage is most dangerous (§4.7), so the
concession is itself a control.
Deferred, unchanged: T5b/B (the persisted cross-run link graph) waits for a consumer that owns corpus storage; §7.2 dormant cross-run links remain a documented residual until then.
Splittable early win (optional): text-only consumers can be wired at v0.1.0 today — for them the guard is already complete. Deferred with stream 2 by default; pull forward only on explicit request.