1
0
Fork 0
Commit graph

52 commits

Author SHA1 Message Date
0df7e87c2f test(docs-scripts): the census re-derived the URL reader it measures, and nothing would have caught the drift
`docs/fp-sweep.py` and `docs/rawhtml-census.py` produce the numbers published in
`docs/LIMITATIONS.md`, and both reach past the public API into private module
state -- `active_content._ACTIVE_TAGS`, `_EVENT_ATTR_RE`, `_URL_ATTR_RE`,
`_has_external_target`, `calibration.RISK_RANK`. A rename inside `src/` broke
them while the suite stayed green, and the breakage would have surfaced months
later, at the moment someone tried to re-measure a published claim. This was the
last uncovered contract in the repo.

The coverage found a live one. `rawhtml-census.has_external_url_attr` parsed
attributes with its own pattern instead of calling the shipped reader, and the
copy had drifted on two shapes:

  - a URL attribute reached through a prefix. `_URL_ATTR_RE` matches `src=`
    inside `data-src=` on a word boundary, so the shipped predicate reads the
    value and blocks; the census's own name table saw `data-src` and skipped it.
  - a multi-candidate `srcset`. The shipped reader splits on `[,\s]+` so a
    relative first candidate cannot mask an external one behind it; the census
    tested the whole attribute value as a single URL.

Both made the `A` candidate rows free documents the shipped predicate keeps --
under-counting against the `PRODUCTION` row printed directly beside them, which
that row exists to expose. The census now delegates to
`active_content._url_attr_is_external`, so there is one reader, not two. This
repo already carries the general form of that lesson in `docs/URL-SHAPE.md`:
three consumers reconstructed a predicate from prose and each got a different
wrong answer.

NO PUBLISHED NUMBER MOVED. Re-measured against all three live populations after
the fix, in one session each: reference-corpus 389 docs (A frees 3, base-url 13,
both 25 -- the numbers in `docs/LIMITATIONS.md`, unchanged), vendor-harvest 187,
generated-notes 550. `PRODUCTION` equals `A + base-url` in all three (108/108,
98/98, 88/88), and vendor-harvest exercises the corrected branch for real (8
external `Card` attributes). The defect was latent, not published.

The tests are scoped to what a test here can honestly hold. The corpora live
outside this repo in private consumer repos, so neither script can be run end to
end from the suite and a stand-in corpus would only pin a fiction. What is
pinned: every imported name still exists with the shape used; `fp-sweep`'s
metric guard fails when the action map is re-mapped (a guard that cannot fail
protects nothing); `measure` still reads `.disposition` and `.assessment` and
excludes empty files from the denominator; the census's in-process patch point
still moves the gate, so a census patching a dead symbol cannot print six
identical rows and read as a finding; `PRODUCTION` equals `A + base-url` across
every branch of the predicate; and both scripts still refuse an argument-less
run rather than measuring nothing.

759 passing (was 736). Coverage matrix unchanged at 128/128 with 6/6 documented
gaps holding. The `736` in `docs/ADOPTION-BRIEF.md` is scoped "as of v0.6.1" and
is correct for that tag; it moves to 759 at the next version bump.
2026-08-11 23:01:58 +02:00
a59184bb7f fix(zwj): the zero-width check tested identity, so every emoji-composed document was hard-blocked
`_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.
2026-08-11 22:14:47 +02:00
1d49f83a61 test(redos): the lexicon-iframe-src row already discriminates at the shared N
Measured both directions at _REDOS_N=100_000: shipped [^><] 0.375s,
vulnerable [^>] 8.95s -- ~24x apart, both ~4-5x clear of the 2.0s bound.
Unlike lexicon-script-tag, no per-row override was needed; recorded the
margin so a future reader can tell the absence of an override here is
measured, not an oversight.
2026-08-11 21:52:04 +02:00
ee73062864 test(redos): the lexicon-script-tag row denied the literal but not the bound
The unit fix alone ("<script>" -> "<script ") wasn't sufficient: at the
shared _REDOS_N=100_000, the now-fixed [^><] pattern's vulnerable
predecessor ([^>]) only measures ~1.2-1.4s, under the 2.0s assert -- so
the row passed under both the safe and the vulnerable form and proved
nothing. Gave this row its own N=200_000, where the unsafe form
measures ~3.7s (fails) and the shipped form ~0.8s (passes). Verified
both directions by temporarily reverting the lexicon pattern and
restoring it.
2026-08-11 21:40:43 +02:00
736f370cfb fix(active-content): raw-html graded two inert shapes HIGH, and the fix moved a second surface
`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).
2026-08-11 16:56:31 +02:00
d1bff6047d measure(fp): the upload door costs a human on 16-53% of benign documents
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.
2026-08-10 21:32:56 +02:00
398eb7407b docs(plan): kill the pointer that sent this session to the wrong section
`PLAN-v1.md`'s Session H block ended with "Neste: Session G", and that line is
a trap two ways over. "Session G" is a WRITTEN section at :231 — and it is
*v1.0 freeze + release*, not the axis separation. No plan section for the axis
separation ever existed; the pointer resolved to this one forward-reference
line. A session following it designs against the wrong section, which is
exactly what this session started doing before measuring the file.

The number was wrong too: 0.4.0 went to the input cap by operator choice, so
the axis separation landed under 0.5.0 instead.

STATE.md is LOCAL-ONLY and gets overwritten every session, so the plan file and
the CHANGELOG are the only durable record. Leaving the line meant the next
session would re-derive the same wrong pointer from the same text.

Also corrects a citation this session nearly propagated: the locked 0.3.1
grading table is at :288-290. `:246-252` is the okf pin discussion, and the
table at :302-309 is the *broken* v0.3.0 one — the row it asserts for an
ordinary image is `fail_secure`, the regression 0.3.1 exists to fix. Citing it
as the baseline would have inverted the check.

Adds the partial-action-map test: every level a caller leaves unnamed must fall
back rather than raise. The existing test passed a complete map, so the
fallback branch had no coverage — and a KeyError there would have been caught
by `guard` and rendered as a fail-closed with a useless reason.

716 passed; matrix 128/128 + 6/6.

Note on the one red seen while landing this: test_output.py's ReDoS wall-clock
budget failed on a loaded run (suite 37s vs 13.9s), then passed alone and in a
clean full run. It asserts `scan_output` under 2.0s, and `output.py` neither
imports `disposition` nor references it in code — its three mentions are
docstring prose — so this change cannot reach that timing. Load, as STATE
documents, not a regression.
2026-08-10 21:01:19 +02:00
de097110d2 feat(disposition): separate the assessment axis from the action
`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.
2026-08-10 20:55:46 +02:00
b90233481a feat(active-content,okf): bound the last two detection surfaces
`scan_active_content` called directly and `okf.link_graph` were the two surfaces
still reading attacker-supplied text with no cap — the first reached by an
adapter that wants the active-content classes alone, the second running a
`findall` over every body in a bundle. Both are detection-shaped, so they
truncate and flag rather than raise the way the transform surfaces do: what a
detector shortens is its own coverage, not the caller's content.

Truncation is only honest if it is visible, so neither goes quiet: the scanner
emits `active:oversize-input` (LLM10), and `link_graph` records
`(from_id, body_length)` in `LinkGraphResult.truncated` — the field that lets a
caller tell "no links past here" from "no links read past here".

Reached through `scan_output`, the text is already under that surface's cap and
`max_scan_chars` is now passed down, so the flag is raised once, there.
2026-08-10 14:48:54 +02:00
0add3e7e96 docs(readme,limitations): stop advertising a version that was not chosen
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.
2026-08-02 21:21:21 +02:00
2d98d6809d feat(sanitize,fence,neutralize): reject oversize input instead of half-transforming 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.
2026-08-02 21:13:08 +02:00
73fa1b99ae fix(sanitize,okf,active_content): three quadratic patterns, two on the input path
The generalised sweep found what 0.3.2's hand-written rows missed. All three are
the documented class -- a run in front of a required literal that never arrives,
so every start position rescans the tail -- and all three are worse than the
0.3.3 findings, because `sanitize`, `neutralize`, `scan_active_content` and the
okf link graph apply NO input cap. `scan_lexicon`/`scan_output` are the only
entry points that do, so there is no ceiling to extrapolate to.

  sanitize._HTML_COMMENT_RE   `<!--`*100_000        20.1s, exponent 1.96-2.14
  active_content.URL_IN_TEXT_RE  `<a `+`A`*100_000  12.99s / 14.9s, exponent ~2.0
  okf._MD_LINK_RE             `[`*100_000            7.1s, exponent 1.99-2.05

Each fix is the one the pattern's own shape allows, not a copied choice:

  - The comment stripper drops the regex for `str.find`. Excluding `<` would lose
    every comment containing markup; bounding the run would be a carrier bypass
    of the exact construct the stripper exists to remove.
  - `URL_IN_TEXT_RE` bounds its scheme run to an RFC 3986 scheme (`{0,63}`).
    Bounding is safe *here* only because it is a defanger inside a tag already
    flagged `active:raw-html`. A lookbehind was measured too and rejected: it
    drops `-http://evil.com`, a one-character evasion. Bounded: 0.185s at 1M.
  - `_MD_LINK_RE` excludes `[`, matching `active_content.MD_LINK_RE` exactly,
    including the nested-label trade already documented there.

`sanitize` claimed "no catastrophic backtracking" in a comment; that claim was
wrong in the same way `output`'s was before 0.3.2, and is corrected in place.

676 tests (+10), coverage 128/128 + 6/6 gaps, sweep clean across 150 patterns.
The okf destination run gets no row: `[^)\s]+` cannot fail, so a row for it
could never go red.
2026-08-01 20:06:36 +02:00
b8028ba870 fix(lexicon): two quadratic patterns, reachable through the output gate too
The input-path duty `8deca93` scoped. All 83 lexicon patterns measured arm by
arm; two are quadratic, same shape 0.3.2 fixed -- a run in front of a required
literal that may cross the pattern's own opening anchor. Exponent 1.98 over five
points, so quadratic, not exponential.

  markdown:link-anchor-injection  `[`          1.91s @8k   ~8.3h at the cap
  markdown:link-anchor-injection  `[system](`  0.006s @8k  ~89s at the cap
  markdown:link-ref-comment       `[//]: # (`  0.22s @8k   ~1.0h at the cap

Not input-path-only: `scan_lexicon` runs on the output path, so `scan_output("["
* 100_000)` took 334.7s. 0.3.2's "last quadratic site on the output path" was
false when written -- its sweep drove `[` only through `scan_active_content`.

Fix is anchor exclusion, not bounding (bounding attacker-controlled content is a
one-line bypass). The excluded char is `(`, not the obvious `[`: excluding `[`
drops `[//]: # (see [x] then ignore this)`, which no other pattern catches. The
anchors contain `(` too, so it telescopes at zero measured recall cost.

N is per row deliberately. The URL arm ran 0.9s UNFIXED at N=100_000 -- under the
2.0s bound, so that row could not have failed. Measured at N=300_000 instead,
where crafted (8.10s) and legitimate (0.926s) separate 8.8x.
2026-07-31 21:50:51 +02:00
cff043787d fix(output): 19 quadratic regex runs on the output path, worst ~5.7h at the cap
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.
2026-07-31 18:31:58 +02:00
f74245f01f test(output): the "pathological" DoS payload is not pathological -- bound was 6% over
Measured this session, the reason the bound was flaky at all:

  size-matched ordinary prose  3.50-4.20s  (cold up to ~4.2s)
  the "pathological" blob      3.35-3.86s warm, 4.70s cold
  ratio patho/ordinary         0.93x, and 0.96x with the order swapped

The blob is the FASTER side. The test therefore never proved what its comment
claimed -- it measured throughput on 1MB of text, not catastrophic backtracking,
and could only fail when the process was cold or the machine loaded. That is
exactly how it failed: 4.39 / 4.46 / 4.92s against a 5.0s bound.

Catastrophic backtracking IS covered, elsewhere and properly, by
test_lexicon.py::test_redos_pathological_subagent_input_returns_fast, which
crafts against a known-bad nested `.*?` pattern (0.137s against a 2.0s bound --
14x headroom, measured, left untouched).

So: raise the bound rather than make it relative. A relative bound was the other
option on the table and the measurement killed it -- with a ratio below 1.0 you
would need k >= 3 to clear the noise, and an assertion with 3x headroom over a
case already under 1.0 can never fire. Vacuous, plus it would add a second
cold-start asymmetry and double the wall clock.

10.0s is ~2.4x the slowest observed legitimate run (ordinary prose, cold). The
comment now carries the measurements, says the name overstates the payload, and
warns that the 1_000_200-char size is deliberate -- 200 over the max_scan_chars
default, so the test also exercises the truncate-and-flag oversize path.

No production code changed. 642 passed.
2026-07-31 17:18:15 +02:00
2d5a9cd0c7 docs(okf): the mapping class is inexpressible, and T2 binds import not emission
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.
2026-07-27 09:11:22 +02:00
684ce3a45f docs(url-shape): make the rule reconstructable, and record what three corpora measured
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.
2026-07-27 08:56:24 +02:00
956c835d38 docs(limitations): bound two URL-shape false positives with field measurements
Two consumers measured the 0.3.1 URL-shape rule against real corpora on the
same day. Record what they found, and pin the dispositions both of them
inferred wrongly.

Percent-escape FP: zero occurrences across both corpora (0/347 external URLs
in a 527-document vendor-docs corpus, 0/81 in a capture store). This bounds
the shape rather than closing it -- a consumer that slugs filenames from
titles produces %20 systematically, and that corpus is still unmeasured.

Query FP: the over-block that actually occurs. The two corpora hit disjoint
benign populations -- 16/16 publisher-authored utm_* tracking versus 35/35
content identity (?v=, ?channel_id=) where the parameter IS the resource.
An allowlist keyed on tracking-parameter names resolves the first entirely
and the second not at all, so no parameter-level remedy covers both. That
is input to the 0.4.0 axis-separation scope, not a fix here.

Both consumers reported dispositions they had inferred rather than run, and
both were wrong: a query-carrying link is MEDIUM, so it is held or warned,
never hard-failed. tests/test_wiring.py now pins that, plus the active-tag
gate -- counting raw HTML tags overcounts what the gate flags, since
formatting markup is inert and only name/on*=/URL-attr tags are active.

593 passed.
2026-07-25 20:56:14 +02:00
6e9b8168e3 fix(calibration): grade active content on URL shape, not construct type
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.
2026-07-25 15:36:02 +02:00
f5eae9a16e test(coverage): runnable threat-coverage matrix (real-case validation gate)
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.
2026-07-15 11:20:22 +02:00
ee402e4ea8 refactor(calibration): consolidate tunable thresholds into calibration.py
Session D: move every calibration constant (entropy floors 5.4/128, 5.1/64,
4.7/40 + shape floors; MAX_SCAN_CHARS; rot13-min; cognitive-load lengths
2000/2500; disposition ranks; active-content severities) into one documented
calibration.py, so a parallel Node/TS port can mirror exactly the same numbers.

Pure refactor, zero behavior change: calibration is a leaf module (imports only
report.Severity) that entropy/lexicon/disposition/active_content now source
their thresholds from. MAX_SCAN_CHARS is re-exported from lexicon so output.py
and existing callers are unaffected. The 347 pre-existing tests pass unmodified;
new test_calibration.py freezes the values and asserts each detector actually
reads its threshold from calibration (identity-checked, not a dead copy).
2026-07-15 09:44:53 +02:00
f4e89d2885 feat(egress): decode-rescan feeds base64 plaintext to secret-egress (review MINOR)
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).
2026-07-15 07:11:29 +02:00
0772dafb70 feat(okf): scan reserved index.md/log.md in mode-b import, not path-reject (review MAJOR #2)
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=[]).
2026-07-15 06:43:50 +02:00
4d53765c63 feat(guard): active-content detector wired into the output gate (review MAJOR #1)
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=[]).
2026-07-15 06:11:33 +02:00
ca26e117ea feat(inbox): .xlsx extraction — formula gate, hidden sheets, cell comments (stage 2h) 2026-07-07 07:41:00 +02:00
6f32e70c6d feat(inbox): docx table cells + grouped pptx shapes (stage 2g)
Two structural regions the office extractors missed, no new dependency:
- .docx table cells (they live outside doc.paragraphs);
- grouped .pptx shapes (add_group_shape moves a shape inside the group, so the
  extractor recurses through MSO_SHAPE_TYPE.GROUP to reach it).

Each proven by an injection-in-region REJECT plus a clean-region ADMIT. Tests
310 -> 314.
2026-07-07 07:32:10 +02:00
d8a95e465f feat(inbox): .pptx extraction — speaker notes, off-slide box, alt-text (stage 2e)
The pptx payload hides where an audience watching the slides does not look. The
extractor surfaces all three regions into the concept text so the stage-2 scan
catches the injection:

- speaker notes (slide.notes_slide.notes_text_frame.text);
- off-slide (off-canvas) text boxes (shape.text_frame.text, position-agnostic);
- image/shape alt-text (cNvPr@descr, read off the XML — python-pptx 1.0.2 has no
  stable public accessor across shape types).

Detach-proof: same visible slide without notes ADMITs. python-pptx imported
lazily (dev/showcase-scoped, not a core dep). Tests 305 -> 310.
2026-07-06 11:16:59 +02:00
26a231d6c4 feat(inbox): .docx extraction — hidden runs, comments, core metadata (stage 2d)
The docx payload hides where a human reviewing the file in Word does not look.
The extractor surfaces all three regions into the concept text so the stage-2
scan catches the injection:

- hidden/vanish runs (w:vanish) — still runs, so paragraph.text includes them;
- review comments (doc.comments[].text);
- core metadata properties (subject/keywords/comments/title/category/author).

Detach-proof: the same visible body WITHOUT the hidden run ADMITs, so it is the
extractor surfacing the hidden region that caught it, not merely 'a docx'.
python-docx is imported lazily (dev/showcase-scoped, not a core dep). Tests
300 -> 305.
2026-07-06 11:13:52 +02:00
02d59efeb2 feat(inbox): .csv formula injection + folder walk (stage 2c)
- .csv: cells leading with =/+/-/@ (leading whitespace stripped first) are the
  CSV-injection/DDE vector the guard cannot recognize, so the front-end refuses
  them; the raw cell text is still materialized so a prompt-injection phrase in a
  cell is caught by the stage-2 scan (T1). Detach-proof: plain-cell version of
  the same file ADMITs. Numeric -/+ leads are the accepted FP (honest-limits).
- folder: walked member-by-member with relative paths preserved, so a reserved
  basename member (index.md) lands on the guard's T4 gate; symlinks refused.

Refactor: per-file dispatch shared by top-level drops and folder walk (strict
raises on unsupported top-level suffix, folder skips). Tests 293 -> 300.
2026-07-06 11:10:55 +02:00
52aa40b17a feat(inbox): .zip container threats — zip-slip, zip-bomb, symlink (stage 2b)
The front-end reads zip entries in memory (never extracts to disk), so it owns
the container caps while a traversal entry maps onto the guard's path gate:

- zip-slip: a '../../evil.md' entry materializes onto a traversal concept path
  (preserved verbatim, not normalized) -> stage-2 T4 -> FAIL_SECURE -> REJECT.
- zip-bomb: per-entry + per-archive uncompressed-size caps (OWASP LLM10) refuse
  an oversize entry before its bytes are read; a bounded read defends a lying
  header. Detach-proof: a generous cap admits the same archive, so the cap is
  load-bearing.
- symlink entry: refused at the front-end (no legitimate concept meaning).

Caps are kwargs on extract_inbox/receive (small in tests, generous by default).
Tests 288 -> 293.
2026-07-06 11:07:46 +02:00
24e57ca10b feat(inbox): two-stage upload front-end — text formats (stage 2a)
Realistic-upload showcase (PLAN §247), first slice. A stage-1 front-end reads
dropped files and materializes them into an OKF bundle {concept_path: text} +
provenance; receive() wires extract -> import_bundle -> verdict. This slice
covers .txt/.md (stdlib only); .zip/.csv/folder/.docx/.pptx follow.

- Front-end lives in tests/ (showcase/dev-scoped), core stays stdlib-only:
  dependencies=[] untouched; python-docx/python-pptx added to the [dev] extra
  (used from stage 2d/2e), never a public [extract] extra (not v1 per PLAN).
- .txt injection -> guard T1 -> REJECT; clean .txt -> ADMIT; a dropped .md keeps
  its frontmatter so a dangerous value -> T2 REJECT; an index.* upload
  materializes onto the reserved uploads/index.md -> T4 REJECT.
- Detach-proof: neuter extraction to an empty bundle -> the poisoned upload
  ADMITs, proving the verdict depends on extraction carrying the payload.

Tests 282 -> 288.
2026-07-06 11:04:23 +02:00
0061c42f0b test(okf): OKF inbox showcase — mode-b receive/quarantine gate, end-to-end
The OKF analogue of tests/test_showcase.py: one received external bundle plants
one attack per OKF surface (T1 body + frontmatter-description, T3 non-https
resource, T4 path-traversal + reserved-name, T2 dangerous frontmatter value,
T5a dangerous-scheme link, §7.2 dangling link, homoglyph-obfuscated body) run
through the public okf surface as an upload-inbox consumer would compose it.
Aggregate fails secure -> REJECT; a clean bundle admits (WARN); the log marks
rejected concepts. Detach-proof: an always-admit gate catches none of the
surfaces, so the assertions have teeth. Stream-2 centrepiece (PLAN §212);
Stage-1 realistic upload extraction (§247) is the next increment.

Tests 277 -> 282.
2026-07-06 10:49:01 +02:00
07e0b2153a feat(okf): wire adapter into public API — import_bundle carries link graph, package exposes okf namespace (TDD, +2) 2026-07-06 09:44:41 +02:00
30aa0a42a1 feat(okf): in-import cross-link graph — extract/resolve/reject links, dangling-link signal (T5a/A, TDD, +10) 2026-07-06 09:35:59 +02:00
320a40244f feat(okf): bundle-import iterator (mode b) — per-concept validate+stamp, aggregate disposition, log.md (T7, TDD, +8) 2026-07-06 09:32:50 +02:00
eac3c91b89 feat(okf): provenance stamping — origin/channel -> trust/disposition per concept, log.md entries (T6, TDD, +5) 2026-07-06 07:53:14 +02:00
f9a89938b4 feat(okf): resource-URL https allowlist reject-gate (T3, TDD, +10) 2026-07-06 07:43:37 +02:00
ec121f3259 feat(okf): path/reserved-name validation gate — traversal + index.md/log.md shadow (T4, TDD, +10) 2026-07-06 07:40:56 +02:00
f8bc5db547 feat(okf): whole-concept scan surface — frontmatter values + resource + body (T1, TDD, +6) 2026-07-06 07:39:18 +02:00
22e65dcec5 feat(okf): strict reject-by-default frontmatter parser (T2, TDD, +12) 2026-07-06 07:36:14 +02:00
5397ba15a1 fix(security): harden 5 adversarial-review findings (M1/M2/M3 + m4/m6) via TDD
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
2026-07-05 10:45:05 +02:00
a44cf3c7b7 test(showcase): end-to-end multi-vuln pipeline + adversarial/FP corpora (recall)
Module 11's final deliverable (BRIEF §9, PLAN §132), built last. Exercises the
full public toolkit as a consumer would compose it — the _ingest helper doubles
as the README worked example.

- test_showcase: ONE poisoned artifact planting a vulnerability per detection
  channel — 5 invisible carriers (zero-width/BIDI/unicode-tag/html-comment/
  data-uri), injection across raw + homoglyph + rot13 + whole-string-base64
  (decode-and-rescan) channels with DISTINCT lexicon ids so dedup won't merge
  them, plus a credential the mock transform leaks into its output (LLM02). All
  10 planted labels caught; disposition FAIL_SECURE; egress evidence never
  carries the secret value; a clean document + clean transform WARNs (no FP).
- test_corpus: adversarial recall == 100% over the planted classes (a silent
  drop is a regression), and a false-positive corpus of attack-resembling legit
  content (injection vocabulary, secret-shaped placeholders/varrefs, high-entropy
  checksums) that disposes WARN under a trusted source — most producing NO
  finding (the suppression rules hold). One MEDIUM homoglyph case proves hard-fail
  is an explicit opt-in: same finding WARNs trusted, QUARANTINEs under upload.

Secret fixtures assembled from fragments (gitleaks-safe); every payload verified
against the real detectors before assertion. 12 new tests; 201 green total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 23:34:51 +02:00
0af8f68cae feat(wiring): §6 bookends (prepare_input/screen_output) + full public API + README v0.1 (TDD)
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
2026-07-04 23:34:28 +02:00
1e63643157 feat(grounding): SourceGroundingCheck protocol + pass-through default — the semantic-poisoning seam (TDD) [skip-docs]
Module 10 of the build order — interface only in the stdlib core (PLAN §67/§94,
BRIEF §7/§10). lexicon and entropy see surface signals; they are structurally
blind to semantic poisoning — a factually false or subtly biased claim, in clean
prose, carrying no suspicious token. Catching that needs grounding against a
source of truth (embedding classifier, retrieval check, LLM judge) — a model
call, and models never live in this core.

So this module ships exactly two things and no detector logic:
- SourceGroundingCheck: the text -> Report protocol a [judge] implementation
  must satisfy to plug in. runtime_checkable for a coarse callable-vs-not
  isinstance gate; a conforming impl may be a plain function or a stateful
  callable holding a retriever/client — the grounding source of truth is the
  impl's concern, never a seam parameter. Its findings flow through disposition
  like any other detector's, with zero grounding-specific plumbing.
- no_grounding_check (bound as DEFAULT_GROUNDING_CHECK): the pass-through
  default returning an empty Report. The honest scope note made structural —
  semantic poisoning is not solved at the text layer, and the core does not
  pretend to.

Design choice, tested: the default returns NO finding (not an INFO "unchecked"
marker). An INFO finding would flip every artifact to found and trip the
quarantine_default floor in disposition, quarantining every upload for a check
that never ran. The seam stays silent; the honesty lives in the contract.

8 new tests (pass-through empties, silence on semantic poison, protocol
conformance incl. a stateful judge stub, and a composition test proving a
plugged-in check's HIGH finding fails secure through disposition unchanged);
169 green total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 22:44:41 +02:00
9b75bf0bb7 feat(contract): tool-less + credential-allowlist + env-scoping quarantine asserters (TDD) [skip-docs]
Module 9 of the build order — the differentiator. Where the other modules
detect and report, these are pure functions that ENFORCE an invariant and
raise: the write-time analogue of runtime least-privilege. They harden the
call a pipeline makes around its quarantined transform; the library makes no
model call itself.

Three asserts, matching the reusable-contract checklist (BRIEF §6 steps 3-4):
- assert_tool_less(request): the model request carries no tool surface —
  tools/functions/tool_choice/function_call/mcp_servers, populated. Covers
  both Anthropic and OpenAI request shapes; empty/None is genuinely tool-less.
- assert_credential_allowlist(env, allowed): the process env holds no
  credential beyond this stage's allowlist (subset = least-privilege; the
  enrichment stage sees only the model key, never the publish credential).
- scoped_env(env, allowed): the isolation primitive — strips off-allowlist
  credentials, keeps PATH etc.; the assert then passes by construction.

Generalized from claude-code-llm-wiki tools/wiki_ingest/enrich.py
assert_quarantine (a pipeline-specific gate) into framework-agnostic pieces.
Two deliberate safety choices inherited from the reference: credential
detection is name-based (env VALUES are never read — value-scanning is the
output module's job); and a raised ContractViolation names only the offending
KEY, never a value, so it is safe to route to an alert channel (minimal-alert,
BRIEF §6 step 8). CREDENTIAL_NAME_RE ported verbatim from the proven reference.

23 new tests; 161 green total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 20:44:20 +02:00
409b847c42 feat(disposition): source-trust + provenance gate — WARN|QUARANTINE|FAIL_SECURE, compound + fail-closed (TDD) [skip-docs] 2026-07-04 20:34:32 +02:00
19981623f5 feat(output): report-only OUTPUT gate — compose + secret egress (TDD) [skip-docs]
Module 7 of the build order: the last gate before model output is persisted.
Composes scan_lexicon + scan_entropy over the emitted text, feeds each base64
blob entropy decoded back through the lexicon (decode-and-rescan → decoded:*
findings with blob offset), and adds the LLM02 secret/credential egress layer
(cloud/provider keys, PEM headers, DB conn-strings, JWT, labelled
password/secret/api-key assignments) with placeholder/varref FP-suppression.

Report-only (never mutates; neutralize is the separate opt-in mutator). Evidence
carries only a description + match length — never the secret value. Self-safe:
single input-size cap, linear egress patterns. PEM patterns use -{5} form so the
module itself never trips a secret scanner. 26 new tests; 108 green total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 20:18:46 +02:00
78c9f2f7f1 feat(neutralize): opt-in pure defang of active-content output (TDD) [skip-docs]
Build-order step 6. Close the EchoLeak class (CVE-2025-32711): active content in
persisted model OUTPUT that a downstream renderer auto-fetches or makes clickable,
exfiltrating data zero-click. These carriers are neither injection strings nor
high-entropy, so lexicon + entropy miss them — a distinct control (OWASP LLM05,
Improper Output Handling).

Defang, don't delete. URLs in active-content position are rewritten to a
non-resolvable but auditable form (https://evil.com -> hxxps://evil[.]com;
data:/javascript: colon neutralized to [:]); raw active HTML is escaped so a
renderer shows inert literal text. Visible information survives review; only the
machine-actionable affordance dies. Dot-defang is idempotent (never [[.]]).

Six classes, each a Finding: markdown-image (HIGH, the zero-click primitive),
inline-link (MEDIUM), reference-link definition (MEDIUM, the documented
image-filter bypass), angle-bracket autolink (MEDIUM), raw active HTML (HIGH,
inherently-active tag OR event/URL attribute — benign <b>/<em> left untouched),
standalone data: URI (HIGH). Processing order prevents double-counting.

Opt-in and separate: calling neutralize() IS the opt-in to mutate; the report-only
gate stays pure (design principles 3 & 4). Byte-identical on clean output, mirror
of the sanitizer invariant. Scope conceded in the docstring: a targeted defanger,
not a full HTML sanitizer.

17 tests: byte-identity + FP guards (lone <>[], metadata:, benign HTML), image
defang + non-resolvability, secret-exfil URL, inline/reference/autolink, raw-html
escape + script neutralization, data: URI, no double-count, counts, source.

[skip-docs]: README positioning + honest-limitations remains the deliberate
build-order step-11 deliverable (steps 1-5 likewise left README frozen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 18:55:12 +02:00
5fb7e0c9fa feat(fence): randomized unspoofable delimiter + attacker marker-strip (TDD) [skip-docs]
Build-order step 5. Wrap untrusted content in a quarantine fence a downstream
trusted prompt can rely on: everything between the delimiters is data, never
instructions.

Two load-bearing properties:
- Per-call cryptographic nonce (secrets.token_hex, 128-bit) in the delimiter, so
  an attacker embedded in the payload cannot forge the matching closing marker to
  break out — the nonce is unpredictable and fresh every call.
- Marker-strip FIRST: fabricated fence markers already in the payload are removed
  (any/no nonce, case-insensitive, ReDoS-safe negated-class regex) and flagged as
  fence:marker-injection (HIGH, LLM01) before wrapping — defense in depth against
  a lucky guess of the static skeleton, and it surfaces the attempt.

Pure text -> (fenced_text, report, nonce); only mutation is the marker-strip.
Nonce exposed so the caller can reference the fence in the trusted prompt.
10 tests: wrap/preserve, per-call randomness, nonce length, breakout containment,
marker-strip (+ case-insensitive), prose-word FP guard, source, empty input.

[skip-docs]: README positioning + honest-limitations is a deliberate build-order
step-11 deliverable (steps 1-4 likewise left README frozen). README status line
("pre-implementation") is stale and flagged for the step-11 refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8GmKRCdsPjWYAKWsNgeQS
2026-07-04 18:23:35 +02:00
f397cd94e1 feat(lexicon): JSON injection lexicon + variant-set scan (TDD) [skip-docs]
Build order step 4 — the load-bearing port from the llm-security seed
(injection-patterns.mjs + string-utils.mjs), stdlib-only.

- injection_lexicon.json: 83 patterns (CRITICAL/HIGH/HYBRID/MEDIUM) as the
  single source of truth (regex + id + severity + owasp + desc), compiled once
  by a thin loader. Decoupled from the engine for a future TS port.
- scan_lexicon(text, source, max_scan_chars) -> Report: matches every pattern
  against a deduped variant set (raw / normalized / homoglyph-folded / rot13),
  plus unicode-tag presence signal and the cognitive-load trap.
- normalize_for_scan chain ported: unicode-tags -> bidi -> HTML-entities ->
  unicode/hex/URL escapes -> whole-string base64 (reuses entropy.try_decode_base64)
  -> collapse letter-spacing; plus fold_homoglyphs / rot13.
- Self-safety (OWASP LLM10): input-size cap (scan prefix + flag oversize) and
  ReDoS-safe port — the two nested-.*? sub-agent patterns bounded to
  (?:\S+\s+){0,N}?; verified true positives still fire.
- Non-Latin data (homoglyph map, BIDI block) built from explicit code points;
  JSON non-ASCII kept as \uXXXX escapes.

24 tests; 55 green total.

[skip-docs]: README positioning + honest-limitations is a deliberate build-order
step-11 deliverable (steps 1-3 likewise left README frozen). README status line
("pre-implementation") is stale and flagged for the step-11 refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8GmKRCdsPjWYAKWsNgeQS
2026-07-04 17:20:21 +02:00