okf answered the exposure question with a measured no — zero call sites for the
three capped surfaces anywhere in their src/, and their screen_output path
reaches only scan_output, which truncates. So the blast radius is zero and 0.3.5
would have hurt nobody.
It is still 0.4.0. Adding a raise to a previously total public function is
breaking under SemVer, and this file declares SemVer; a survey that comes back
clean tells you the upgrade is easy, not that the contract held. The version
number describes the change, not the luck of who happens to call it.
Cost, stated: 0.4.0 was penciled in for the axis separation. That moves to 0.5.0.
Tags are never moved, so the choice is made once.
README still advertises v0.3.4 — it is updated after a clean-venv install proves
the tag resolves, so the install block never points at something unproven.
llm-ingestion-okf moved to the range >=0.3,<0.4 (their f536e13, message received
mid-session). The entry claimed exact git pins for both, so nobody would pick the
behaviour change up without re-pinning. That is true for linkedin-studio and
false for okf: any 0.3 release lands on them at their next resolve.
This is why the release is held. okf.py calls none of the three capped surfaces,
so their exposure may well be zero — but that is theirs to measure, not ours to
assume, and the version number depends on the answer: on the 0.3 line it arrives
unasked, as 0.4.0 it sits outside their ceiling.
The scanners cap by truncating: they return findings, so reading a prefix costs
detection in the tail and nothing else. The three transform surfaces return
*content*, where the same move is not available — a shortened document is silent
data loss, and a transformed prefix followed by an untransformed tail is a
bypass, since the attacker chooses where in the document the payload sits.
So they fail secure instead. Above MAX_INPUT_CHARS (1 000 000) sanitize, fence
and neutralize raise OversizeInputError. sanitize is step 1 of prepare_input and
only ever removes, so that one refusal bounds the whole input path.
OversizeInputError subclasses ContractViolation: a pipeline already bracketing
its quarantined stage keeps failing closed rather than meeting a type it has
never heard of. It inherits the alert-routable property too — sizes in the
message, refusing surface in details, no input in either.
Invariant now pinned across all three: returned text is always fully
transformed, or not returned at all.
Still uncapped and recorded in LIMITATIONS: scan_active_content called directly
(through scan_output it inherits that cap) and the okf link graph. Both are
detection-shaped, so truncate-and-flag transfers unchanged — mechanical, not
policy.
699 tests (+23), coverage 128/128 + 6/6, ReDoS sweep 0 candidates / 150.
0.3.3 swept 83 lexicon patterns arm by arm and left the other ten regex-bearing
modules on 0.3.2's hand-written rows. Generalising the sweep found three more,
and the two on the input path matter more than the count suggests: `sanitize` is
step 1 of `prepare_input`, and `MAX_SCAN_CHARS` is applied in `scan_lexicon` and
`scan_output` only, so there was no cap to extrapolate to. That missing input
cap is now a documented residual of its own -- extending it changes the contract
for existing callers and is not something to smuggle into a ReDoS patch.
Version synced in all four places + CHANGELOG. LIMITATIONS 30 -> 31 items;
the 0.3.3 entry claiming the lexicon sweep's scope is corrected in place, since
"the output path" was never the whole surface either.
676 tests, coverage 128/128 + 6/6 gaps, sweep clean across 150 patterns.
Version synced across the four locked points (pyproject, __version__, README
badge, README install pin) + CHANGELOG.
Corrections this release carries, both measured rather than reasoned:
- docs/LIMITATIONS.md said the script-tag change removed "the last"
quadratic-backtracking site on the output path. It did not. Corrected, with
the 334.7s gate measurement that falsifies it.
- README's coverage line claimed 126/126 classes; the matrix reports 128/128.
Stale since before v0.3.2. Test badge was 642, actual 666.
New residual recorded (LIMITATIONS, now 30 items, README synced): the sweep
flags on timing above a 1.5ms noise floor at N=8000, so an arm hiding under it
could still cost ~23s at the cap. What this supports is "no arm worse than ~23s",
not "no quadratic arm remains" -- and the blind spot is demonstrated, since a
generic-payload pass found only one of the two patterns.
666 tests green, coverage matrix 128/128 with 6/6 gaps holding, exit 0.
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.
v0.3.0 made the untrusted upload path unusable: measured on both doors, an
ordinary remote image fail_secure'd and an ordinary link/autolink/refdef
quarantined, so only documents without external references persisted.
Two independent defects compounded; neither fix works alone:
1. `markdown-image: HIGH` fired on any external image. The exfil primitive is a
URL that moves bytes outward, not an image. `is_ordinary_url` now grades on
shape - http(s)/protocol-relative, no query, no userinfo, no percent-escape,
no opaque host label or path segment -> LOW; anything data-carrying keeps the
carrier's severity. raw-html and data: URIs stay HIGH unconditionally.
Opacity reuses entropy's primitives; floors calibrated against real doc URLs
(worst legit token H=4.08, exfil segments 4.36-4.54) and frozen in
calibration.
2. The quarantine_default floor fired on ANY finding, a premise that broke when
every ordinary link became a finding. It now fires at MEDIUM+ - a no-op for
every detector that shipped before 0.3.0 (no LOW/INFO exists), which is what
makes this a patch rather than a minor.
The corpus blind spot that let this pass 522 green tests is closed: the FP
corpus carries realistic markdown and is asserted on the OUTPUT gate under
PRESET_USER_UPLOAD, with a counter-corpus of exfil-shaped URLs that must still
block. Beaconing and short opaque segments are conceded in LIMITATIONS and
asserted by the coverage matrix rather than papered over.
No new public API; no new preset (0.4.0 work); allow_reserved default unchanged.
Version-sync across pyproject, __init__, README badge/status/install pin and
CHANGELOG, and cut [Unreleased] to [0.3.0].
Why minor: 0772daf made allow_reserved default to True on okf.import_bundle,
so a consumer who upgrades without touching their code gets a LOOSENED gate --
a reserved index.md/log.md in a received bundle is now scanned and may become
mergeable, where v0.2.0 rejected it unconditionally. Under 0.x a >=0.2,<0.3
pin absorbs a 0.2.1 silently but stops at 0.3.0, which is the signal such a
change should send. Found by llm-ingestion-okf, our first real downstream
consumer, against main.
Development Status stays 3 - Alpha and the README keeps "the public API may
still change": this ships the hardened surface (Sessions A/A2/B) to consumers
who are still pinned at v0.2.0 and therefore have none of it. It is not the
v1.0 freeze -- that stays gated on the first real integration coming back
green, which is exactly what 0.3.0 makes possible.
Verified: 522 passed; coverage matrix exit 0; version string present in all
four files; no dangling 0.2.0 outside CHANGELOG history.
A downstream consumer (llm-ingestion-okf) found that main had diverged from
v0.2.0 on okf.import_bundle: 0772daf adds allow_reserved defaulting to True,
so a reserved index.md/log.md in a received bundle is now scanned rather than
unconditionally rejected. Their tests pin the v0.2.0 reject, so a 0.2.1 cut
from main would have changed downstream behaviour silently.
Checking that turned up a wider hole: [Unreleased] documented only the
coverage matrix and the docs work, while three commits since the tag change
what an unchanged caller observes -- 0772daf (loosens), 4d53765 (active
content now reaches decide(), HIGH/MEDIUM with compound escalation) and
f4e89d2 (base64-decoded plaintext now hits secret-egress). None were listed,
so CHANGELOG could not answer "is this bump safe?".
Records all three under Changed, and states that the next release is 0.3.0 --
under 0.x a >=0.2,<0.3 pin absorbs a 0.2.1 silently but stops at 0.3.0, which
is exactly the signal a gate-loosening change should send.
Answers the gap that the README said what it does NOT stop (a long limitations
section) but never plainly listed what it DOES. Add a 'What it protects against'
section high up: attack classes grouped by OWASP anchor (LLM01 injection + 83
lexicon classes + carriers, LLM02 egress, LLM05 EchoLeak, LLM06 agency, LLM10
fail-secure, OKF T1-T6, container front-end), each driven by a live coverage-matrix
payload. Move the full honest-limitations list to docs/LIMITATIONS.md; README keeps
a high-impact summary + link. Net: protection and limits read in balance, 261 -> 216
lines. Coverage 126/126 and 522 tests unchanged; every class listed is real.
Rewrite the README value proposition to make the necessity land on mechanism,
not adjectives: write-time ingestion is the trust boundary query-time guardrails
structurally cannot see; an OKF/LLM-wiki has no format-level authenticity, so the
ingestion pipeline IS the trust boundary. Add a first-class 'OKF / LLM-wiki
support (shipped)' section for import_bundle mode-b (per-concept gates). Fix stale
test badge (357 -> 522) and drop the brittle module count. Tighten prose; retain
all honest-limitations items (a shipped control). Every symbol/preset/command
verified against v0.2 code.
Add a single declarative manifest proving, in one place, every vulnerability
class the guard stops — and the documented gaps it does not. This is the
real-case validation gate ahead of any v1.0 freeze (v1.0 stays parked until
verified on real cases).
- src/llm_ingestion_guard/coverage.py: stdlib-only manifest (CORE_CASES) +
narrated runner. `python -m llm_ingestion_guard.coverage` prints
class -> OWASP -> expected -> observed -> verdict; exit 0 iff every caught
class is caught and every documented gap holds. 126 caught classes + 4 gaps.
Lexicon cases are generated from load_lexicon() via a payload dict, so a
pattern with no payload fails loudly at import (self-verifying).
- tests/test_coverage_matrix.py: asserts total recall, that every documented
gap still holds, and completeness (every lexicon id + every OWASP anchor has
a case). Adds the full 25-pattern LLM02 secret-egress set (fixtures assembled
from split tokens so no secret shape sits in source) and the container-layer
front-end classes (CSV formula-injection, zip-slip, zip-bomb, symlink).
- README + CHANGELOG: point to the runnable matrix.
+165 tests (357 -> 522). No core dependency added.
Pre-release hardening from an independent adversarial review; each fixed
test-first (failing test -> fix -> green). 214 tests pass.
- entropy (M1): decode-and-rescan now runs BEFORE false-positive suppression,
so an SRI/media-prefixed injection blob is still decoded and lexicon-rescanned.
Suppression gates only the entropy finding, never the decode.
- output/disposition (M3): the invisible-carrier invariant now holds on the
persist gate. scan_output flags zero-width/BIDI presence and disposition
treats those + lexicon:unicode-tags-present as any-tier carriers, so a carrier
in model output fails secure even under a trusted policy.
- contract (M2): assert_credential_allowlist catches a bare <PROVIDER>_KEY
(e.g. STRIPE_KEY) that the old regex silently missed (fail-open). Deliberately
broad: also flags PARTITION_KEY/SORT_KEY as loud, allowlistable FPs -- fail-loud
beats fail-silent for an isolation control.
- disposition (m6): guard runs decide inside its guarded block -> total
fail-closed even on a malformed report.
- output (m4): egress placeholder suppression anchors word markers (example,
todo, ...) to a word boundary, closing a fail-open where a real secret merely
containing such a word was suppressed.
Docs: CHANGELOG Security subsection; README honest-limit for lexicon dedup (m5,
documented tradeoff, not fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
Add .gitignore (Python stack + obligatory lines; /STATE.md kept local-only
until a private remote exists), CHANGELOG.md, and the Communication patterns
section in CLAUDE.md. STATE.md is gitignored — must never reach the public
open/ mirror.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8GmKRCdsPjWYAKWsNgeQS