a59184b fixed a hard-block that reached every emoji-composed document, and
llm-ingestion-okf was told so -- while the fix sat untagged on main. A consumer
cannot pin what has no tag, so the notice was a promise the repo had not kept.
SURFACES MOVED (the eight the 0.5.0 sweep established, plus the ninth verified)
pyproject.toml 0.6.0 -> 0.6.1
__init__.py 0.6.0 -> 0.6.1
CHANGELOG.md [0.6.1] entry, [Unreleased] reset to "Nothing yet."
README.md badge, install tag @v0.6.1
docs/ADOPTION-BRIEF.md `v0.6.0` x2, and 727 -> 736 passing
CLAUDE.md one clause: ZWJ judged by context, not identity
SECURITY.md `0.6.x` -- still true at 0.6.1, verified, not moved
docs/BRIEF.md `v0.6 (alpha)` -- likewise
README.md:36 `v0.6`, alpha -- likewise
forge description re-verified THIS release: 178 codepoints, no version
claim. Not inherited from 0.6.0's check.
PATCH, NOT MINOR -- and it was asked, not assumed. 6bcb898 made "loosens the
upload door" the criterion for minor, and this loosens it too, so the reading
was put to the operator with the count rather than settled quietly. 0.6.0 was a
policy choice (`<base>` left the active name set by decision); this restores a
contract the module already published, against a class never meant to be
blocked. Measured loosening: 1 document of 1126 across the three FP populations
(reference-corpus 1/389, vendor-harvest 0/187, generated-notes 0/550), against
0.6.0's 25 + 2 + 2. Operator chose patch.
VERIFIED ON THE BUMPED TREE, NOT THE PRE-BUMP ONE
736 passed; coverage 128/128 recall, 6/6 documented gaps hold
`git grep '0\.6\.0'` returns provenance only -- LIMITATIONS history, the
census script, source docstrings, tests, README's pre-0.6.0 numbers
docs/LIMITATIONS.md: 34 items, README says 34
__version__ and pyproject agree at 0.6.1
no tracked file carries a stale current-state version or test count
The sweep tool itself was wrong first: `git grep -E '\b727\b'` returns nothing,
because POSIX ERE has no `\b`. An empty result read as "clean" when the claim
was there. Every sweep above is plain `git grep`.
Still to prove before this is announced: a scratch-venv install at the tag with
the resolved version asserted -- the README line above is only true once v0.6.1
resolves.
`_ZERO_WIDTH` (sanitize, input) and `_ZERO_WIDTH_CPS` (output,
`_scan_invisible_carriers`) tested U+200D on codepoint membership alone.
`disposition._CARRIER_LABELS` grades both as any-tier FAIL_SECURE with no
appeal, so any first-party document containing a ZWJ-composed emoji --
professions, families, skin tones, flag variants -- was hard-blocked forever.
Reported by ms-ai-architect; confirmed here against the code.
The strip was the worse half and was not in the report: sanitize *removed* the
joiner, silently decomposing the emoji into two unrelated ones. A module whose
contract is "only ever removes carriers" was corrupting content.
The fix is the one our own lexicon row `unicode:zero-width-in-word` (`\w[ZW]\w`)
already used: judge the joiner by CONTEXT, not identity. A ZWJ is exempt only
when BOTH neighbours are emoji-context codepoints. Half-context is not context,
so `a<ZWJ>{emoji}` stays a carrier and an attacker cannot buy exemption with a
single emoji.
Blocks, not an emoji table. Measured against Unicode 17.0's
`emoji-zwj-sequences.txt`: 1614 RGI sequences use 122 distinct codepoints
adjacent to a ZWJ, and the five ranges cover 122/122. The measurement earned
its keep -- the hand-reasoned candidate table missed U+2194, U+2195 and U+2B1B.
Shipping the RGI list itself would be exact on the day it landed and stale at
the next Unicode release, reopening this same false positive for every new
emoji; whole blocks carry the unassigned headroom (458 Cn codepoints) that
future emoji are allocated into, so the table does not age.
The predicate is defined once in sanitize and imported by output. A second copy
is how the input side stops flagging while the output side keeps blocking; the
cross-surface test asserts the two agree on six inputs.
Two residuals, both in LIMITATIONS (33 -> 34, README bumped): a ZWJ between two
emoji is now exempt and could carry a covert channel (one emoji per bit, cannot
split a word); and U+200C (ZWNJ) still has no context test, so Persian, Arabic
and Devanagari documents -- where it is orthographically required -- stay
blocked. That needs a script-based criterion and no corpus is here to verify it
against, so it is parked as a known FP class rather than guessed at.
736 green (was 727), coverage 128/128, 6/6 documented gaps hold.
The narrowing landed in 736f370 and its prose asserted `0.6.0` in thirteen places
-- README's public front page among them -- while every version surface still read
0.5.0. That is the same defect class the last three commits were spent correcting:
a published number no measurement backs. Two ways out, land it or neutralize the
references; operator chose to cut.
SURFACES MOVED (the eight the 0.5.0 sweep established, plus the ninth verified)
pyproject.toml 0.5.0 -> 0.6.0
__init__.py 0.5.0 -> 0.6.0
CHANGELOG.md [Unreleased] -> [0.6.0], fresh [Unreleased]
README.md badge, `Status: v0.6`, install tag @v0.6.0
SECURITY.md support window `0.5.x` -> `0.6.x`
docs/BRIEF.md `v0.5 (alpha)` -> `v0.6 (alpha)`
docs/ADOPTION-BRIEF.md `v0.5.0` x2, and 717 -> 727 passing
CLAUDE.md `v0.5 (alpha)` -> `v0.6`, plus what 0.6.0 changed
forge description 178 codepoints, carries no version claim -- verified,
not moved. A surface can be checked and stay still.
Measurement provenance is left alone, as in 0.5.0: `tests/test_disposition.py`'s
"0.5.0 axis separation", `disposition.py` and `calibration.py` docstrings,
LIMITATIONS' 0.5.0 reference, every dated claim in docs/PLAN-v1.md. Bumping those
falsifies the record rather than updating it.
MINOR, NOT PATCH: 0.6.0 loosens the upload door. A document whose only finding was
a doc-relative URL attribute on an inactive tag name, or an attribute-less
`<base />`, now WARNs where it was held -- 25 documents in the reference corpus, 2
in each wiki corpus.
VERIFIED BEFORE COMMITTING, NOT AFTER
727 passed; coverage 128/128 recall, 6/6 documented gaps hold
docs/LIMITATIONS.md: 33 items, README says 33
rawhtml-census PRODUCTION row equals `A + base-url` on all three populations --
the shipped predicate measured, not a hypothesis about it
redos-sweep: 0 candidates of 152 patterns
docs/fp-sweep.py still imports the private names it reaches into
no tracked file carries a stale current-state version claim
Still to prove before the tag: `git show` over this commit's README, and a clean
clone install at this sha.
`is_active_tag`'s URL-attribute branch was a presence test: any element carrying
`href=`/`src=`/`action=` graded HIGH regardless of where the URL pointed. An MDX
`<Card href="/en/agent-sdk/quickstart">` reaches no attacker-controlled host, and
neither does APIM policy XML's `<set-header>`. It now requires an external target
-- the rule the markdown paths have applied since 0.3.1. `<base>` left the active
name set in the same change: HTML's `<base>` has its whole affordance in an `href`
the attribute branch still catches, and APIM's attribute-less `<base />` is inert.
Measured before and after in ONE session against one corpus state, because two of
the three corpora are living and a split would mix this with re-harvest drift:
reference-corpus 389 docs 133 -> 108 (ceiling 107)
vendor-harvest 187 docs 100 -> 98 (ceiling 62)
generated-notes 550 docs 90 -> 88 (ceiling 49)
96% of the achievable reduction in reference-corpus, 5% in the wiki corpora. The
two classes had to be measured TOGETHER -- alone they free 3 and 13 documents,
together 25, because a document carrying one usually carries the other.
The second surface: `neutralize` imported `is_active_tag` by name, so this would
have silently narrowed the opt-in mutator too -- and no test discriminated the two
halves, since every `neutralize:raw-html` payload stays active under any narrowing
considered. That test is written first here. The predicates are now separate
symbols; the mutator keeps defanging anything, because over-defanging is auditable
and blocks nothing while under-defanging hands a human a live construct.
Behaviour change: a document whose only finding was one of these classes now WARNs
instead of holding. Detection is unchanged -- 128/128 classes, 6/6 gaps hold.
Self-safety: reading an attribute VALUE needs a pattern the presence test lacks. It
reuses the same literal alternation so no new run shape enters the table; its
`_REDOS_PAYLOADS` row denies the `=` the pattern requires, since a unit supplying it
matches at once and never exercises the run (the lexicon's `script-tag` row is the
cautionary case). 0.031-0.046s across five attack shapes at 100_000 chars against a
2.0s bound; `docs/redos-sweep.py` reports 0 candidates of 152. An attribute the
presence test saw but the value parser cannot read counts as external -- fail secure.
`docs/rawhtml-census.py` gains a PRODUCTION row that re-measures the shipped
predicate rather than a hypothesis, so a published number and the code cannot drift
apart unnoticed. README's limitation count moves 34 -> 33.
727 passed (was 717).
The "Measured, document by document" bullet attributed `active:raw-html` in
52 of vendor-harvest's 98 to the relative-URL-attribute over-reach. Measured
by re-running the gate with that branch narrowed to external targets only, it
frees 1 document there, 1 in generated-notes, 3 in reference-corpus. The
over-reach is real and nearly free; the rate is driven by the NAME branch
(`<a>` 298, `<frame>` 94, `<img>` 63 per wiki corpus; `<base>` 113 in
reference-corpus).
Two further corrections the census forced:
- The over-reach is not only MDX. In reference-corpus it lands on Azure APIM
policy XML (`<set-header>` 50, `<ip-filter>` 3, `<set-query-parameter>` 2).
- New bullet: APIM's attribute-less `<base />` collides with HTML's `<base>`,
whose whole affordance is `href`. 30 of reference-corpus's 389 documents.
Also pins corpus provenance. "Every population was swept twice and reproduced
its counts exactly" is false for two of the three: vendor-harvest and
generated-notes live in claude-code-llm-wiki, which re-harvests per Claude
Code release. At its commit aba87e2 they now give 100/187 and 90/550;
reference-corpus is static and reproduced 133/389 exactly. The published table
keeps its original counts — measurement provenance is never silently bumped —
and gains the provenance it was missing.
README limitations count 33 -> 34.
0.5.0 is the axis separation `de09711` built: `Risk` (assessment) alongside
`Disposition` (action), `Policy.action_map` as the supported override, and the
fail-closed path pinned to both axes. Additive and measured to be so — 717
passing with no test changed, matrix 128/128 with 6/6 documented gaps, the
`PRESET_USER_UPLOAD` grading table unchanged row by row. Plus the field FP
measurement (`d1bff60`) and the 0.3.3 behaviour-change correction (`d3d0928`).
WHY THIS COMMIT TOUCHES EIGHT FILES AND 0.4.0's TOUCHED THREE
0.4.0's release commit updated CHANGELOG, pyproject.toml and __init__.py, and
deferred README deliberately: the install block should not name a tag before a
clean-venv install had proven it resolved. Sound reasoning, and the proof step
never ran — so tag v0.4.0 permanently advertises v0.3.4. The tag is not moved.
The ordering is.
Sweeping every tracked file for a version claim, instead of ticking the four
surfaces the checklist named, found five more that no release had ever touched:
SECURITY.md "pre-1.0 (0.2.x)" — the one with a consequence for an
outsider: it named a support window two minor lines
behind the code.
README.md "**Status:** v0.3" — the front page, stale since 0.4.0.
docs/BRIEF.md "v0.2 (alpha)" — stale since 0.3.0.
CLAUDE.md "v0.2 (alpha)" and "12 moduler" where src/ has 15.
docs/ADOPTION-BRIEF "703 passing" where the suite is at 717.
Measurement provenance is deliberately left alone: "New in v0.4.0", "verified
identical on 0.2.0 and 0.3.1", "measured against the v0.3.1 tag", every
"post-0.4.0 tree" in LIMITATIONS. Bumping those falsifies the record instead of
updating it, which is why this cannot be a sed sweep — the surfaces have to be
sorted into current-state and provenance before a single edit.
Found because llm-ingestion-okf took our report of this defect class as a
hypothesis about their own repo, measured it, found a worse instance on their
public front page, and sent back the generalization: writing down a trap is not
the same as applying it.
VERIFIED BEFORE COMMITTING, NOT AFTER
717 passed; coverage 128/128 recall, 6/6 documented gaps hold
docs/LIMITATIONS.md: 33 items, README says 33
fp-sweep reproduced all three published numbers exactly on the bumped tree —
vendor-harvest 98/185 (53.0%), generated-notes 88/547 (16.1%),
reference-corpus 133/389 (34.2%) — and self-docs runs clean, so the
untested script survived the bump it imports names from
forge description: 178 codepoints, under the 180 cap
no tracked file carries a stale current-state version claim
Still to prove before the tag: a clean-venv install from this commit's sha, and
`git show <sha>` over the README. The install proves the package builds; only
the grep proves the text the tag will carry is right. That second check is the
one the old ordering could not perform, because by then the tag existed.
Every field measurement this repo had published was per URL. None answered
what a consumer actually feels: how often does an ordinary document fail to
persist unattended? Three benign populations, each against its own
denominator, run through screen_output under PRESET_USER_UPLOAD and counted
at document granularity:
vendor-harvest 98 of 185 (53.0%) non-WARN -- 64 fail-secure, 34 held
generated-notes 88 of 547 (16.1%) non-WARN -- 61 fail-secure, 27 held
reference-corpus 133 of 389 (34.2%) non-WARN -- 80 fail-secure, 53 held
The number is bad and ships as measured; PLAN-v1 committed to that in advance
("et roedt FP-resultat er like verdifullt"). The response is a documented
limitation, not a recalibration: moving the grading fires the locked
linkedin-studio notification promise, and the drivers are residuals
LIMITATIONS already concedes. Counted at each document's worst severity,
active:raw-html -- the MDX-component over-reach -- is a top driver in 52 of
vendor-harvest's 98 and 53 of generated-notes' 88; about ten per population
are genuinely injection-shaped text, which security-adjacent documentation
honestly contains.
Method traps closed rather than stepped in:
- The unit is in the number. Document-level rates are NOT comparable to the
URL-level 16/16, 28/28, 149/1694 above them, and the three rows are not
summable -- the 2400 != 2401 defect class one level up.
- The gate is the strict one. The trusted door WARNs every non-CRITICAL
finding, so it would have handed back a beautiful, meaningless near-zero;
it is printed as a footnote and labelled structurally blind.
- "not WARN" is only a risk statement while the default action map sends
exactly NONE and LOW to WARN. action_map became a supported override last
commit, so the equivalence is pinned in the suite and the sweep aborts if
it breaks.
- Ground truth for benign is provenance, not inspection, and says so.
- The populations are disjoint as documents but not independent as content:
184 of generated-notes' 547 are same-named derivatives of vendor-harvest.
Measured, not assumed, and the two rows read as one observation.
Also fixed: tests/test_wiring.py credited a consumer's capture store with
35 of 35 query-carrying URLs. That consumer retracted the number the next day
and re-measured 28 of 28 on the same 81-URL corpus. LIMITATIONS was corrected
then; the comment was not, so a retracted figure has been sitting beside a
live one since 07-27.
717 tests (was 716, none changed), coverage matrix 128/128 with 6/6 gaps
holding, every population swept twice with identical counts.
`decide` returned a `Disposition` — WARN / QUARANTINE_REVIEW / FAIL_SECURE —
which names an ACTION. But BRIEF design principle 4 says the library reports
and the pipeline decides, and disposition.py admitted the gap in its own
docstring: "It imposes no blocking of its own." So we returned an action we
cannot enforce, having discarded the judgement that produced it. A consumer
wanting different behaviour had to reinterpret the action itself — which is
why a consumer ends up pinning our GRADING: the action was all they got.
`Risk` (NONE/LOW/ELEVATED/SEVERE) now carries that judgement, and
`Policy.action_map` lets a caller map it to their own action. Both overlays
move the assessment rather than the action, so a custom map cannot silently
drop the compound escalation or the quarantine floor. `guard`'s fail-closed
path pins both axes and deliberately bypasses the map: downgrading SEVERE
means "I accept this class of finding", never "I accept a crashed scanner".
`DispositionResult.assessment` is required with no default. `Risk.NONE` is the
natural-looking default and the wrong one — a site that forgot the field would
report clean, and the axis would fail open.
MEASURED ADDITIVE, not assumed:
- 703 -> 715 tests, no existing test changed
- coverage matrix 128/128 recall, 6/6 documented gaps still hold
- the PRESET_USER_UPLOAD grading table locked in 0.3.1 re-measured row by
row: ordinary link/image/autolink/refdef -> warn on BOTH doors, unchanged
Both locked consumer promises in docs/PLAN-v1.md were checked against that
measurement and neither fires: the grading is untouched (linkedin-studio), and
the relative-target asymmetry is untouched (llm-ingestion-okf).
Scope held to disposition, per PLAN-v1.md:380. Version stays 0.4.0; the 0.5.0
bump lands in its release commit with all five version surfaces at once —
that is the fix for the defect where the v0.4.0 tag carried a 0.3.4 README.
Records limitation 32: `Severity` still carries disposition intent on the
DETECTION side, which this change does not address and cannot without moving
the grading.
The tag was pushed first and installed into a clean venv before this commit:
0.4.0 resolves from the forge, both new caps fire, and the transform raise is
present. Only then does the install block point at it — a README that advertises
a tag nobody has resolved is how an install line goes stale without anyone
noticing.
The adoption brief was three releases behind (`v0.2`, 126 classes, 4 gaps, 522
tests). Re-measured rather than incremented: 128/128, 6/6, 703 passing.
The per-repo gate flagged four ERRORs and two WARNs. Fixed, in the order the
work actually gets done in:
MISSING
- `## Honest limitations` -> `## Known limitations`, `## Out-of-scope
(documented boundary)` -> `## Non-goals`. Both sections existed under names
no reader or agent scans for; the contract wants predictable top-level
headings. Pointers followed: the in-README anchor, SECURITY.md's out-of-scope
preamble, CONTRIBUTING.md's scope section, and the consumer-facing
docs/ADOPTION-BRIEF.md. Historical records (CHANGELOG, docs/PLAN.md,
docs/OKF-INGESTION-BRIEF.md) keep the name they were written with.
WEAKENING
- README now opens with one line identical to the forge description, above the
badges. That is the only place a machine can check description == README.
- Forge description shortened 207 -> 178 codepoints (bound 180), and the same
string written to pyproject's `description` so the fourth copy cannot drift.
The tests badge is dropped, not updated. `tests-699_passing` as a static image
is a claim dressed as evidence: there is no CI runner on this forge, so nothing
verifies it. Replaced with the honest substitute in Install — the single command
that runs the suite from a clean clone, stated together with the fact that
nothing runs it automatically.
Two WARNs deliberately left standing:
- H1 `# llm-ingestion-guard` != repo name. The register itself records
llm-ingestion-guard as "a package, not a repo"; the H1 names what you pip
install. Renaming the repo is the operator's call, not this commit's.
- The `Status` badge trips the same claim-badge regex, but `alpha` asserts
maturity, not a run — the same reason version/license/platform are exempt.
Measured false positive in the gate's classifier, reported upstream.
LIMITATIONS said "Since 0.3.5" and the README stated the new refusal in present
tense under a 0.3.4 badge and a @v0.3.4 install pin. Both are on the public
mirror, and both contradict the same commit's CHANGELOG ("the version this lands
under is not yet decided") and what we told llm-ingestion-okf an hour earlier:
no tag until they answer.
A reader installing the advertised pin would get a library that does not do what
the README says. Same failure the clean-venv rule already covers for tags —
extended to behaviour.
Also fixes a test that passed for the wrong reason: `"101" not in details` is
tuple membership over ("sanitize",), trivially true, and would stay true if a
size were ever folded into the string. Now a substring check, matching the
canary assertion two lines above it.
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.
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.
The test badge still said 577 after two commits that added 65 tests, and the
coverage-matrix claim said 126 where CORE_CASES now holds 134 payloads. Both
verified by running rather than by reading: pytest reports 642, and
len(coverage.CORE_CASES) is 134.
A consumer measured our frontmatter gate against 0.2.0 and asked us to confirm
against a newer guard, correctly noting that a divergence would be a version
difference rather than a contradiction. Measured at 0.3.1: no divergence. Both
their results reproduce exactly.
Two things are sharper than what we documented yesterday:
ALL THREE ROUTES TO A MAPPING FAIL, each on a different rule -- flow {k: v} on the
disallowed value-start indicator, block on the nested-mapping check, dotted keys on
the key pattern. So the mapping CLASS has no expressible form; it is not a choice
between two shapes where one is better. That matters because OKF v0.2's `generated`
IS a mapping (`by` is required when it is present), so it cannot be expressed at
all. Pinned by a test asserting the three failures stay distinct.
T2 RUNS ON DOOR C ONLY. parse_frontmatter is referenced nowhere in the door A/B
persist path, so the same frontmatter that FAIL_SECUREs through import_bundle
passes screen_output unremarked. The grammar bounds what a consumer can RECEIVE,
never what a producer can EMIT -- which is the question a consumer's emitter design
was blocked on.
Also corrects one of our own rows: we reported "sources block list" as a single
FAIL_SECURE case. The parser distinguishes three shapes -- flat scalars parse
correctly, one key per item misparses silently to a string, two keys per item hard-
rejects. A one-element `verified:` block list passes today.
631 -> 642 passed. README item count synced.
Three consumers reconstructed is_ordinary_url from prose we sent in coordination
messages and each produced a different wrong number on a real corpus: one omitted
the base64 20-char floor and fired on path words like /blog/; one omitted the
opaque-token condition entirely and undercounted; one computed Shannon entropy
over whole filenames instead of tokens and concluded the 4.4 floor over-blocks
ordinary documents. Same cause each time -- our prose described the rules without
their tokenizer.
docs/URL-SHAPE.md states the algorithm in order, spells out the separator class
and all three length floors, and lists the three reconstruction errors as worked
counter-examples. Its example table is parsed and asserted against the real
predicate by tests/test_url_shape_doc.py, so the reference cannot drift from the
code -- all 18 rows verified load-bearing.
LIMITATIONS.md brought current with the field measurements:
- Percent-escape is no longer zero. Two English corpora measured 0; a 389-file
Norwegian/Microsoft corpus found 10, all Norwegian (%C3%B8, %C3%A5 are just
o-slash and a-ring). It is a non-ASCII-language tax, and both zero-measuring
corpora being English was a sampling bias invisible from inside.
- The query over-block now has THREE disjoint benign populations: utm_* tracking,
content identity (?v=, ?channel_id=), and Microsoft Learn's ?view= version
selector. No parameter-level remedy covers any two, which moves this from a
conclusion to a settled constraint on 0.4.0.
- Legitimate CDN asset ids trip the hex branch permanently; the branch is otherwise
precise (no other FP in 2401 distinct URLs) and stays.
- Raw HTML with a relative URL attribute is HIGH though it reaches no external
host, and end tags are counted.
- OKF frontmatter: a one-key block-sequence item is silently misparsed to a string
where two keys hard-reject, so a pointer can ride past the resource allowlist.
Consequence: a conformant OKF v0.2 concept cannot traverse door C at all, since
both backward-breaking migration targets are nested. Fail-secure, but a
compatibility wall that needs a deliberate parse-safety decision.
- A persist gate cannot cover execution risk, and that boundary is unowned.
New behaviour claims are pinned by tests so a closed concession fails and forces
this doc to be updated. 593 -> 631 passed.
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.
The Install section promised `pip install llm-ingestion-guard`, but the
distribution is not on PyPI — a downstream consumer following the README
would fail. Replace it with the channel that actually works, verified in a
throwaway venv against the public mirror:
pip install "llm-ingestion-guard @ git+https://.../@v0.2.0"
Anonymous HTTPS read on the open/ mirror is confirmed, so consumer CI needs
no deploy key. Also states the two things a consumer must know up front:
a git URL is a PEP 508 direct reference (exact tag, not a >=0.2,<0.3 range;
range pinning arrives with a Forgejo PyPI registry at the first patch release
or second consumer), and vendoring is unsupported because it severs the
security patch channel.
Answers the install-channel question raised by llm-ingestion-okf via coord.
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.
Reframe .pdf from a 'known gap'/TODO to a deliberate concession across the
honest-limits and OKF-upload docs. A top-level .pdf drop is already refused as
an unsupported format (inbox_frontend.py raises on the else branch), not
half-scanned; adding a PDF parser + reportlab (solely to author white-on-white
test fixtures) is disproportionate for a dev-scoped showcase, and the OCR /
font-render stego carriers a PDF would smuggle are out of scope regardless.
- README honest-limits: .pdf = concession, not TODO; only the numeric CSV FP
remains a known gap.
- docs/PLAN.md: upload table .pdf row marked 'conceded'; honest-scope paragraph
names .pdf; assertions tightened to 'every accepted format'.
Closes the last format gap before v1.0 freeze (Session G). No code touched;
357 tests green.
- README: tests badge 275->357; status v0.1->v0.2 (repo is 0.2.0; the v1.0
bump belongs to the Session G freeze, not this docs pass); add three
honest-limits — lone-HIGH-in-trusted-prose->WARN, vacuous quarantine-floor,
Cyrillic/Latin homoglyph-mix false positive.
- docs/BRIEF.md: drop "No code yet" pre-implementation framing -> implemented v0.2.
- docs/OKF-INGESTION-BRIEF.md 4: correct cross-link control language —
absolute https / references/ targets are spec-permitted, not rejected.
- Add SECURITY.md (private Forgejo disclosure) + CONTRIBUTING.md (stdlib-only
core, Iron-Law TDD, no trailers, Forgejo-only invariants).
Replace the unverified/absolute novelty statement with the defensible
four-part-contract form, verified against a focused adversarial PyPI+GitHub
survey (2026-07-15):
- BRIEF §11: 'assumed, not verified' -> verified survey with sources. Names
aig-guardian (real, query-time; blurs only the minimal-dep-library
differentiator), GuardLLM (nearest neighbour, runtime hardening, no
scan-before-persist / capability isolation / fail-secure), and ipi-scanner
(orphaned placeholder repo, recorded for honesty not as prior art).
- README: differentiator moved from 'library vs hosted/model' to the full
four-part write-time contract.
- PLAN §27-31: drop the unverifiable 'the first' superlative.
Also promotes the v1.0 session plan (PLAN-v1.md) and the cross-model review
(review-2026-07.md) into docs/ on the open/ mirror, referenced by PLAN.md's
re-sequencing addendum.
Output gate step 3 now runs scan_secret_egress over every decoded base64
blob's plaintext, not only scan_lexicon. A base64-wrapped credential that
formerly vanished (decode fed the lexicon, which has no secret patterns)
now surfaces as decoded:egress:* carrying the blob offset. Evidence stays
length-only, so the decoded finding never leaks the secret value.
Hex-wrapped secrets remain a documented honest-limit (entropy exposes
decoded plaintext for base64 only). README honest-limits + CLAUDE.md
Kontekst updated; 3 tests added (347 passed, was 344).
A received OKF bundle MAY legitimately carry index.md (directory listing, read
first under progressive disclosure) and log.md (update history) at any level
(spec §3.1/§6/§7). import_bundle previously hard-rejected those basenames in the
T4 path gate, so a conformant third-party bundle was over-blocked in full
(FAIL_SECURE) — and because the reject fired before scan_concept, index.md's
body (the highest-priority injection surface) was never scanned.
import_bundle now defaults allow_reserved=True: reserved basenames are scanned
as structural files (path-safety checks — traversal / absolute / backslash / .md
— still apply). The shadow-reject (an *upload* masquerading as index.md) is
preserved: the front-end passes allow_reserved=False so a materialized upload
landing on a reserved basename is still refused. That front-end opt-in was
required to keep the shadow-reject once the default flipped (not in the plan's
Filer set; traced from the code).
- okf.py: validate_concept_path/_validate_concept/import_bundle gain the
keyword; validate_concept_path default stays False (strict standalone).
- tests: +3 (legit index/log admit; injection in index.md body caught;
okf_version frontmatter admits). Per-concept-iteration test switched to a
traversal vector; mode-b showcase's index.md surface reframed from
reserved-name-reject to index.md-body-scan.
- README honest-limits + CLAUDE.md context note the mode-b/upload distinction.
Suite: 341 -> 344 passed. Core invariant intact (dependencies=[]).
Close the EchoLeak wiring hole (CVE-2025-32711 class): markdown images/
links, reference definitions, autolinks, raw active HTML and data: URIs
now surface as report-only findings (active:*, OWASP LLM05) in
scan_output step 6, so screen_output and okf.import_bundle dispose of
them instead of admitting them with findings=[].
- new active_content.py: canonical home of the shared pattern table +
scan_active_content; neutralize refactored to import it (mutating API
and behavior unchanged, all neutralize tests pass as-is)
- images/links flagged only for absolute/protocol-relative URLs:
relative in-bundle links are legitimate wiki/OKF mechanism (principle 5)
- evidence carries defanged URLs only (hxxps://evil[.]example)
- EchoLeak vectors planted in both showcases; detach proofs cover them
- README export list + checklist step 6, CLAUDE.md context line updated
Suite: 321 -> 341 passed. Core invariant intact (dependencies=[]).
Concede the binary layer that survives text extraction as out-of-scope (a
shipped control): VBA/macros, OLE/embedded objects, OCR-needed image text,
font/render stego, encrypted files. Point to the two-stage OKF inbox showcase as
the worked example, and list the known gaps (.xlsx/.pdf deferred, .docx tables,
grouped .pptx shapes, numeric CSV -/+ FP).
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
Mirror the badge convention from the ms-ai-architect plugin README — shields.io
STATIC badges (img.shields.io/badge/), which work on Forgejo where dynamic
build-status endpoints do not. Type-adapted for a library rather than a plugin:
the Platform slot becomes python-3.10+, the content-count slot becomes
tests-201-passing, plus an honest status-alpha badge.
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