Version sync in all four places (pyproject, __version__, README badge, README
install pin) plus the changelog entry for cff0437.
Two claims that were about to ship in the release note did not survive being
measured against a v0.3.1 worktree, and are corrected in LIMITATIONS first:
- "Report-only, so the cost is a review, not a block" was wrong twice. HIGH under
a low-trust preset is fail_secure, not a review -- report-only means the text is
never mutated, not that a finding cannot block. And the new script-tag false
positive costs no consumer a disposition at all: any text containing a literal
`<script>` already produced active:raw-html at HIGH on 0.3.1, so the same prose
disposed fail_secure under PRESET_USER_UPLOAD before this change and after it.
The fail-open that was closed is narrower for the same reason -- it existed only
in scan_lexicon called on its own; through either composed gate, raw-html
already caught the unclosed tag. What changed is the label, not the outcome.
- "The realistic long value is still caught by egress:jwt-token" was true and
hid the part that matters. Measured at the 257-char boundary: a generic long
password still trips entropy:base64-blob at CRITICAL, disposition unchanged.
A JWT used as a DB password is the case that moves -- its remaining detections
top out below CRITICAL, so the any-tier block is lost and PRESET_TRUSTED_SOURCE
drops from fail_secure to quarantine_review. PRESET_USER_UPLOAD still
fail_secures. Recorded as a behaviour change in the changelog, not buried.
The first probe for that boundary used a 300-char run of "A" and found nothing on
either version: all-same-char values are suppressed as placeholders. The probe was
wrong, not the pattern.
662 passed, coverage matrix exit 0, LIMITATIONS still 29 items = README's 29.
17 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.3.2] — 2026-07-31
Denial-of-service fix. Upgrade from 0.3.1. The output gate could be made to spend hours on a single call by crafted input it accepts by design. No disposition changes for ordinary documents — the one measured exception is listed under Known behaviour changes below. The v0.3.1 tag is not moved.
Fixed — 19 quadratic regex runs on the output path
scan_output claimed LLM10 self-safety on the grounds that its patterns contain no
nested quantifiers. That is true and it is not the property that matters. 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 quadratic is sufficient here.
Measured, not argued (Python 3.14, this machine):
| Input | Time through scan_output |
|---|---|
<a: × 100 000 (300 KB) |
458.7 s |
| size-matched ordinary prose | 0.31 s |
| same payload extrapolated to the 1 000 000-char input the gate itself accepts | ~5.7 hours for one call |
max_scan_chars does not mitigate this. It bounds the input; quadratic work on a
bounded input is still hours. That claim was stated in both output.py and
calibration.py and is corrected in both.
The fix is per pattern, not uniform:
active_content+ the lexicon table (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.*-connstrsecret egress (4 runs) — bound the password at the newMAX_CONNSTR_VALUE(256) incalibration. The exclusion fix is unavailable: the anchor character is/, and passwords containing/are the common case (measured — they match today).hybrid-xss:script-tag— had neither option, since its run is the script body, which may legitimately contain<. It now matches the opening tag and no longer requires</script>.
Known behaviour changes
Two, both measured against the v0.3.1 tag rather than reasoned about:
- A JWT used as a DB password, over 256 chars, is no longer CRITICAL. The
remaining detections (
entropy:base64-blobHIGH,egress:jwt-tokenMEDIUM) top out below CRITICAL, so the any-tier block is lost: underPRESET_TRUSTED_SOURCEsuch a document moves fromfail_securetoquarantine_review. UnderPRESET_USER_UPLOADit stillfail_secures, and a generic long password still tripsentropy:base64-blobat CRITICAL with no change at all. The credential is never silently missed; on one preset it is held for review instead of halted. hybrid-xss:script-tagnow fires on prose that merely mentions<script>— and this costs no consumer a disposition. Any text containing a literal<script>already producedactive:raw-htmlat HIGH on 0.3.1, so the same document disposed identically before and after. The label is new; the outcome is not. By the same measurement, the fail-open this closed (an unclosed<script>alert(1)) was confined toscan_lexiconcalled on its own — through either composed gate,active:raw-htmlalready caught it.
Both are recorded in docs/LIMITATIONS.md (still 29 items — these replace nothing).
Method note
The defect was found by a composed-gate DoS test that stayed red after every
individual scanner had been made linear; the remaining 813× was the lexicon's six
html-obfuscation patterns. A per-scanner test alone would have shipped it. The
static shape analysis used to find candidates also missed [\s\S]*? in
script-tag — the sweep that matters is measurement, not a regex over regexes.
662 tests pass (was 642), and the suite is faster than before the fix.
[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-okfprojected 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-imagewas 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.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-htmlanddata:URIs have no ordinary form and stay HIGH unconditionally. Opacity reusesentropy'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 incalibrationwith the rest. - The
quarantine_defaultfloor 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 intests/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 — soscan_outputstep 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.3pin 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_bundleno longer path-rejects reserved basenames. At v0.2.0,index.md/log.mdanywhere in a received bundle was an unconditional per-concept hard reject (FAIL_SECURE), andimport_bundletook no keyword for it. The newallow_reservedkeyword defaults toTrueon 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 passallow_reserved=Falseto keep the shadow-reject there;validate_concept_pathstill defaults toFalse.- Active content now reaches the disposition engine.
scan_outputstep 6 runsscan_active_content, so markdown images/links, reference definitions, autolinks, raw active HTML anddata:URIs surface asactive:*findings (OWASP LLM05 — the EchoLeak / CVE-2025-32711 class) instead of being admitted withfindings=[]. 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 bothscreen_outputandokf.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_bundlemode-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/objectcoercion 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),resourceand body all go throughscan_output. (T1)validate_concept_path— path / reserved-name gate: rejects..traversal, absolute paths andindex.md/log.mdshadowing; returns the concept-ID. (T4)validate_resource_url—resourcehttps 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 aslog.mdlines. 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.mdlinks (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— sharedFinding/Report/Severity/Sourcetypes.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— theSourceGroundingCheckseam 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_outputflags zero-width / BIDI presence (output:zero-width-present,output:bidi-present) anddispositiontreats those pluslexicon:unicode-tags-presentas any-tier carriers, so a carrier in model output fails secure even under a trusted policy.contract—assert_credential_allowlistcatches a bare<PROVIDER>_KEY(e.g.STRIPE_KEY); the previous regex silently missed it (fail-open). The rule is deliberately broad (also flagsPARTITION_KEY/SORT_KEYas loud, allowlistable false positives) — fail-loud beats fail-silent for isolation.disposition—guardrunsdecideinside 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).