# 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] ## [0.3.1] — 2026-07-25 > **Regression fix. Upgrade from 0.3.0.** v0.3.0 made the high-untrust upload path > unusable for ordinary documents — measured, not projected. `llm-ingestion-okf` > projected the consequence from the 0.3.0 changelog text *before* the tag was cut; > the release went out without the inbox being read. The v0.3.0 tag is not moved. ### Fixed — the upload path is usable again without losing EchoLeak detection Measured on v0.3.0, both doors (`screen_output` under `PRESET_USER_UPLOAD` and `okf.import_bundle` with `origin=EXTERNAL`): a document with **one ordinary remote image** disposed `fail_secure`; one ordinary link, autolink or reference definition disposed `quarantine_review`. Only documents with no external references persisted. Two independent defects compounded, and both had to be fixed — either alone leaves the path blocked: - **Severity graded on construct type instead of URL shape.** `markdown-image` was HIGH for *any* external image, but the exfiltration primitive is not "an image" — it is a URL that moves bytes to a host the attacker controls. `![diagram](https://example.com/arch.png)` carries nothing. Severity now grades on shape: a URL that only *names* a remote document (http(s) or protocol-relative, no query, no userinfo, no percent-escapes, no opaque host label or path segment) is **LOW**; anything that can carry a value keeps the carrier's full severity. `raw-html` and `data:` URIs have no ordinary form and stay HIGH unconditionally. Opacity reuses `entropy`'s primitives — decodable base64 (≥20 chars), hex id (≥32), or Shannon entropy ≥4.4 at ≥24 chars — calibrated 2026-07-25 against real documentation URLs (worst legitimate token H=4.08; exfil payload segments 4.36-4.54). New constants live in `calibration` with the rest. - **The `quarantine_default` floor fired on *any* finding.** It rested on the premise that a finding is the exception; adding the active-content detector in 0.3.0 made every ordinary markdown link a finding, and the floor then held ordinary documents for review. The floor now fires at **MEDIUM+**. This is a no-op for every detector that shipped before 0.3.0 — the lexicon holds no LOW/INFO pattern and no other detector emits LOW (asserted in `tests/test_calibration.py`) — which is why this is a patch and not a minor. **Unchanged, deliberately:** no new public API and no new preset (a middle tier is 0.4.0 work); the `allow_reserved=True` mode-b default stands — two independent consumers document it as load-bearing; the gate still never rewrites content. ### Added - **False-positive corpus covers ordinary markdown.** The 0.3.0 corpus had zero markdown links or images, asserted only under `PRESET_TRUSTED_SOURCE` (where every non-CRITICAL finding WARNs anyway), and drove the *input* path — so `scan_output` step 6, where active content actually lives, was never reached. That is how a regression this size passed 522 green tests. The corpus now carries realistic documents and asserts them on the **output gate under the upload preset**, plus a counter-corpus of exfil-shaped URLs (query, base64/hex path segment, percent-encoded payload, opaque subdomain, userinfo) that must still block. - **Two new documented gaps** in `docs/LIMITATIONS.md`, both asserted by the coverage matrix: **pure beaconing** (a bare-path image on a hostile host still fetches, and the fetch is not graded) and **short opaque URL segments** (<24 chars, below what entropy can resolve). Percent-escapes counting as data-carrying is recorded there as a known false positive. ## [0.3.0] — 2026-07-25 > **A minor bump, not a patch — deliberately.** The changes under *Changed* alter what > an existing caller observes with no code change on their side, so a `>=0.2,<0.3` pin > stops here rather than absorbing them silently. Re-test that branch before widening > the pin. Still alpha: the public API may change again before 1.0. ### Changed — observable gate behaviour (re-test before upgrading) Three commits since v0.2.0 change dispositions for an unchanged caller. Two tighten the gate; one loosens it. - **`okf.import_bundle` no longer path-rejects reserved basenames.** At v0.2.0, `index.md` / `log.md` anywhere in a received bundle was an unconditional per-concept hard reject (FAIL_SECURE), and `import_bundle` took no keyword for it. The new `allow_reserved` keyword **defaults to `True`** on this mode-b *received-bundle* path, so those files are scanned — their body is the highest-priority injection surface — rather than refused, and may clear the floor and become mergeable. **This is the one loosening change:** content a v0.2.0 consumer never saw can now reach it, so a consumer whose tests pin the v0.2.0 reject must re-check, not just bump. A front-end materialising individual *uploads* must pass `allow_reserved=False` to keep the shadow-reject there; `validate_concept_path` still defaults to `False`. - **Active content now reaches the disposition engine.** `scan_output` step 6 runs `scan_active_content`, so markdown images/links, reference definitions, autolinks, raw active HTML and `data:` URIs surface as `active:*` findings (OWASP LLM05 — the EchoLeak / CVE-2025-32711 class) instead of being admitted with `findings=[]`. These carry real severities (zero-click auto-fetch/execute HIGH, click-required MEDIUM), and two MEDIUM+ findings compound-escalate one tier, so a document that passed clean at v0.2.0 can now WARN, quarantine, or fail secure in both `screen_output` and `okf.import_bundle`. - **Base64-wrapped secrets are now caught as egress.** The output gate's decode-and-rescan feeds decoded base64 plaintext through both the lexicon and the LLM02 secret-egress detector, so a base64-wrapped credential surfaces as `decoded:egress:*` instead of disappearing. Hex-wrapped remains a documented gap (`docs/LIMITATIONS.md`). ### Added — runnable threat-coverage matrix A single declarative manifest (`llm_ingestion_guard.coverage`) that proves, in one place, every vulnerability class the guard stops — and the documented gaps it does not. Two consumers of the same source of truth: - `python -m llm_ingestion_guard.coverage` — a narrated matrix (`class -> OWASP -> expected -> observed -> verdict`); exit 0 iff every caught class is caught and every documented gap holds. Stdlib-only, CI-usable. - `tests/test_coverage_matrix.py` — asserts total recall over the core matrix (carriers, all 83 lexicon patterns, entropy/decode-rescan, active content, the contract asserters, the disposition engine, OKF T1–T7), asserts every documented gap still holds, and guards completeness (every lexicon pattern id, and every OWASP anchor claimed, has a case). Adds the full 25-pattern LLM02 secret-egress set and the container-layer front-end classes (CSV formula-injection, zip-slip, zip-bomb, symlink). +165 tests (357 → 522), no core dependency added. This is the real-case validation gate ahead of a v1.0 freeze. ### Documentation — consumer adoption + README value proposition - `docs/ADOPTION-BRIEF.md` — a self-contained brief a consumer repo (OKF second-brain / LLM wiki) can plan an inclusion from: the write-time trust-boundary argument, the two bookends + 8-step contract, the shipped OKF adapter (`import_bundle` mode-b), how to verify (coverage matrix), how to depend (stdlib-only core), and a checklist for *when/where* to wire it. - README rewritten to lead with the write-time trust-boundary framing, add a first-class **OKF / LLM-wiki support (shipped)** section for `import_bundle`, a concrete **What it protects against** catalogue (attack classes grouped by OWASP anchor, driven by the coverage matrix), and correct the test badge (357 → 522). Every claim verified against the code. - `docs/LIMITATIONS.md` — the full honest-limitations list (15 items + the four documented gaps + out-of-scope) moved out of the README, which now carries a high-impact summary + link, so protection and limits read in balance. ## [0.2.0] — 2026-07-06 ### Added — OKF adapter (stream 1) An OKF (Google Open Knowledge Format v0.1) adapter *on top of* the format-agnostic core (`llm_ingestion_guard.okf`). The core stays `text -> findings`; the adapter knows OKF structure and routes scannable regions into the existing machinery. All TDD (failing test first), +61 tests. Verified against the OKF `SPEC.md` (2026-07-06). See `docs/OKF-INGESTION-BRIEF.md` §8. - `parse_frontmatter` — strict, reject-by-default frontmatter loader; refuses anchors, aliases, explicit tags, merge keys, block scalars and flow collections by construction, so YAML anchor/alias DoS and `!!python/object` coercion cannot occur (not a general YAML parser, by design). (T2) - `scan_concept` — whole-concept scan surface: frontmatter values (incl. `description`, read first under progressive disclosure), `resource` and body all go through `scan_output`. (T1) - `validate_concept_path` — path / reserved-name gate: rejects `..` traversal, absolute paths and `index.md` / `log.md` shadowing; returns the concept-ID. (T4) - `validate_resource_url` — `resource` https allowlist: rejects non-https before commit (reject, not defang — the format imposes no scheme constraint itself). (T3) - `stamp_concept` / `format_log_entry` — provenance stamping: origin × channel → trust × disposition per concept, emitted as `log.md` lines. Trust follows the origin, never the insertion channel. (T6) - `import_bundle` — received-bundle iterator (mode b): validates each concept (path, frontmatter, resource, scan, stamp) independently; one bad concept is rejected fail-secure while the rest are still checked; the aggregate disposition is the most severe. (T7) - `link_graph` / `resolve_link` / `extract_link_targets` — in-import cross-link graph: resolves `.md` links (bundle-absolute or relative) to concept-IDs, flags dangling links (the §7.2 dormant-injection signal) and rejects dangerous-scheme or bundle-escaping targets. (T5a) ### Deferred - Cross-run persisted link graph (T5b) — catching a link planted in one run whose poisoned target is written in a *later* run (§7.2) needs durable graph state whose storage/ownership depends on the consuming pipeline. Deferred to the consumer-wiring stream; cross-run dormant links remain a documented residual risk (README honest-limitations). ## [0.1.0] — 2026-07-06 (alpha) The stdlib-only core, built test-first (TDD) per `docs/PLAN.md`. Tagged `v0.1.0`. ### 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. ### Security Pre-release hardening from an independent adversarial review (all TDD, failing test first): - `entropy` — decode-and-rescan now runs **before** false-positive suppression, so an injection blob prefixed with an SRI/media marker (to dodge the entropy finding) is still decoded and rescanned by the lexicon. Suppression gates only the entropy finding, never the decode. - `output` — the invisible-carrier invariant now holds on the persist gate: `scan_output` flags zero-width / BIDI presence (`output:zero-width-present`, `output:bidi-present`) and `disposition` treats those plus `lexicon:unicode-tags-present` as any-tier carriers, so a carrier in model output fails secure even under a trusted policy. - `contract` — `assert_credential_allowlist` catches a bare `_KEY` (e.g. `STRIPE_KEY`); the previous regex silently missed it (fail-open). The rule is deliberately broad (also flags `PARTITION_KEY`/`SORT_KEY` as loud, allowlistable false positives) — fail-loud beats fail-silent for isolation. - `disposition` — `guard` runs `decide` inside its guarded block, so a malformed report can no longer escape the fail-closed guarantee. - `output` — secret-egress placeholder suppression anchors word markers (`example`, `todo`, …) to a word boundary, so a real secret that merely *contains* such a word is no longer suppressed (fail-open egress miss closed).