llm-ingestion-pipeline-secu.../docs/LIMITATIONS.md
Kjell Tore Guttormsen 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

226 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Honest limitations
Conceding these plainly is itself a control — it prevents the false assurance that
a green scan means safe content. The README carries a summary of the highest-impact
items; this is the full list, each with the mechanism.
- **Structural unsolvability at the text layer.** Pattern/lexicon detection is
bypassable in isolation; character-injection and novel phrasings evade it. The
*contract* (tool-less transform, capability isolation, fail-secure) carries the
security — the lexicon is defense-in-depth, not a wall.
- **A lone HIGH finding in trusted prose disposes to WARN, not quarantine.**
Under `PRESET_TRUSTED_SOURCE`, trust-scaling downgrades a single HIGH to WARN,
and one HIGH is not "compound" (escalation needs ≥2 findings at MEDIUM+). So a
HIGH injection reproduced verbatim under a *trusted* policy persists with a WARN.
This is by design: if your "trusted" sources can carry attacker-influenced text,
run them as untrusted (or add a quarantine floor).
- **The quarantine floor fires at MEDIUM+, and is a no-op under the shipped upload
preset.** Through 0.3.0 `quarantine_default` floored *any* finding to
QUARANTINE_REVIEW. That premise ("a finding is the exception") broke when the
active-content detector made every ordinary markdown link a finding, so 0.3.1
raised the floor to MEDIUM+. Under `PRESET_USER_UPLOAD` (untrusted) a MEDIUM
already escalates on trust alone, so the floor still changes no outcome there; it
is live only for a caller-defined *trusted* policy that opts into
`quarantine_default`. Documented so the preset is not over-read: a LOW finding on
an upload now disposes WARN.
- **Semantic / factual poisoning is invisible** to lexicon + entropy: a false claim
in clean prose carries no suspicious token. **Highest impact for a wiki.** The
`grounding` module ships only a `SourceGroundingCheck` *seam* — the deterministic
core does not judge semantics; a `[judge]` implementation must be plugged in.
- **Adversarial-ML evasion** can survive normalization; **tokenizer mismatch**
between scanner and model leaves gaps. **Latent / dormant memory poisoning** is
not judgeable at write time.
- **Dormant / broken-link injection** in a linked corpus (e.g. an OKF bundle): a
link to a not-yet-existing target passes a per-concept write-time scan clean — the
payload is planted later, when that target is written. `link_graph` surfaces the
*dangling* edge as the signal, but catching the payload needs cross-write re-scan
over time (the caller's disposition call).
- **OKF reserved files (`index.md` / `log.md`).** In a *received* bundle these are
legitimate structure, so mode-b `import_bundle` scans their body and frontmatter
(an injection in a directory listing is caught) rather than path-rejecting the
conformant bundle. A front-end materialising individual uploads keeps the opposite
rule (`allow_reserved=False`): a reserved basename is a listing-shadow and refused.
- **OKF frontmatter is a restricted grammar, and a one-key block-sequence item is
silently misparsed.** Gate T2 accepts a line-oriented subset deliberately — full
YAML is a larger parse-attack surface than a write-time gate needs. Nested mappings
and flow collections (`[a, b]`, `{k: v}`) are *rejected outright*, which fails
secure. **All three routes to a mapping fail, each on a different rule** — flow
(`{k: v}`) on the disallowed value-start indicator, block (`k:\n sub: v`) on the
nested-mapping check, and dotted keys (`k.sub: v`) on the key pattern — so the
mapping *class* has no expressible form, rather than one form being preferable to
another. What survives is scalars and flat lists of strings. The defect is between
those two outcomes: a block sequence whose items carry
exactly **one** key parses "successfully" into the wrong type —
`sources:\n - uri: https://e.com/a` yields the **string** `'uri: https://e.com/a'`,
not a mapping, while the same list with two keys per item hard-rejects. A pointer
can therefore ride through in a key the `resource` allowlist never inspects
(`attester:\n - resource: attesters/sql_equality.py` → WARN), whereas a top-level
`resource:` with a relative path correctly fails secure. The shape is not conformant
OKF, so a well-formed bundle will not produce it; a malformed or hostile one can, and
mode-b `import_bundle` writes the merged concept verbatim. Note the three block-list
shapes are *not* one case: flat scalars parse correctly, one key per item misparses
silently, two keys per item hard-rejects.
- **T2 constrains import, not emission.** The frontmatter grammar runs on
`okf.import_bundle` (door C) only — `parse_frontmatter` is referenced nowhere in the
door A/B persist path, so frontmatter that fails secure on import passes
`screen_output` unremarked. The grammar therefore bounds what a consumer can *receive*,
never what a producer can *emit*. Verified identical on 0.2.0 and 0.3.1.
- **Consequence: an OKF v0.2 concept cannot traverse the external-import path.** Both
of v0.2's backward-breaking migration targets are nested — `timestamp``generated.at`,
and body `# Citations` → a `sources` block list of mappings — so a conformant v0.2
concept fails secure at the frontmatter gate. This is the correct direction but it is
a compatibility wall, not a policy: v0.2 support requires a deliberate parse-safety
decision about widening the grammar, and the dangling-or-substituted `executor`/
`attester` pointer question only becomes live once that decision is made.
- **A persist gate cannot cover execution risk.** OKF v0.2 introduces concepts whose
purpose is to *name code to be run* (`runtime`, `executor.resource`,
`attester.resource`). This library answers "is this safe to **store**"; executable
code carries its risk at **run**. A file that is harmless to persist can be harmful
to point at. Upstream defers the attester ABI and sandboxing to a future revision, so
there is no runtime contract to gate against — the execution boundary is *unowned*
across the stack rather than covered by anyone's roadmap, and no tightening of a
write-time scanner would change that.
- **A document that *describes* attacks is a false positive.** Content documenting
prompt-injection payloads (security notes, this project's own corpus) trips
carrier-strip / fail-secure. At the text layer "*about* an attack" and "*carrying*
an attack" are indistinguishable; such content needs a deliberate, explicitly
escaped path, never a silent allow.
- **Bilingual text trips the Cyrillic/Latin homoglyph rule.**
`homoglyph:cyrillic-latin-mix` (MEDIUM) flags a Latin letter adjacent to a
Cyrillic look-alike, so genuine bilingual prose → MEDIUM → under untrusted →
QUARANTINE_REVIEW — a real false positive for an inbox that expects multilingual
content. A calibration fix is pending.
- **Insider in-place edits** by a trusted author are out of the untrusted-content
threat model.
- **Text-only.** The core is `text -> findings`: it parses no files (no
`pypdf`/`python-docx`/archive deps). Extract text first, then scan it with the
high-untrust upload provenance. OCR-embedded instructions and multimodal stego are
out of scope beyond the sanitizer's character-layer stripping.
- **Uploaded files: only the *extracted text* is scanned.** The dev-scoped OKF
inbox showcase (`tests/test_okf_inbox_uploads.py` + `tests/inbox_frontend.py`;
parsers in the `[dev]` extra, never core `dependencies`) reads
`.txt`/`.md`/`.csv`/`.docx`/`.pptx`/`.xlsx`, folders and `.zip`, materializes an
OKF bundle, then guards it. What survives extraction is **out of scope**:
macros, OLE/embedded objects, OCR-needing images, font/render stego, encrypted
files — the binary layer needs a separate scanner. The front-end owns the
container threats it *can* see (zip-slip → path gate, zip-bomb → size cap, symlink
refusal, CSV/XLSX formula-lead cells). **`.pdf` is a deliberate concession:** a
top-level `.pdf` is *refused as unsupported* rather than half-scanned (a PDF parser
is disproportionate for a dev showcase, and the OCR/stego it would smuggle is
already out of scope). One known gap: the numeric `-`/`+` CSV false positive (a
typed XLSX numeric cell does not trip it).
- **Lexicon findings are deduplicated by pattern id** — `count=1` and the first
offset are reported, so a class matched across several channels collapses to one
finding at its first location: a deliberate readability tradeoff.
- **Active-content severity grades on URL shape, so a pure *beacon* is only LOW.**
Since 0.3.1 a URL that merely names a remote document (bare path, no query, no
userinfo, no percent-escapes, no opaque segment) is LOW, and only a URL that can
move bytes outward keeps HIGH/MEDIUM. The deliberate hole: `![x](https://
evil.test/pixel.png)` on an attacker-controlled host still *fetches* when a
renderer touches it, leaking reader IP, user-agent and timing. Grading the fetch
itself would re-block every ordinary document, which is precisely the 0.3.0
regression this replaced — so beaconing is conceded, not covered.
- **Short opaque URL segments slip through the same grading.** Opacity is decided by
`entropy`'s primitives: base64 that decodes to text (≥20 chars), a hex id
(≥32 chars), or Shannon entropy ≥4.4 at ≥24 chars. A shorter payload segment —
`https://evil.test/aGVsbG8gd29ybGQ` — cannot be told from a name, because entropy
is bounded by `log2(length)` at short lengths. Mitigation in depth, not in this
detector: a literal credential in a URL is still caught by the LLM02 egress
patterns in the same `scan_output` pass, whatever severity the carrier gets.
- **Percent-escapes count as data-carrying — a `%20` in a path is a false positive.**
An ordinary link with an encoded space grades as carrying and reaches
QUARANTINE_REVIEW / FAIL_SECURE on an untrusted upload. Obfuscated encoding is a
core exfil primitive and the ambiguous case is put on the review side deliberately;
it is listed here because it is the same *class* of over-block that 0.3.1 fixed,
in a rarer shape. **It is a non-ASCII-language tax, and that is the finding.**
Three consumer corpora measured it (2026-07-25/26). The two English ones found
zero — 0 of 347 external URLs in a 527-document vendor-docs corpus, 0 of 81 in a
capture store. The third, a 389-file Norwegian/Microsoft reference corpus, found
10 distinct real escapes and **every one of them Norwegian**: `%C3%B8` and
`%C3%A5` are simply *ø* and *å* in UTF-8, and legal/government sources turn titles
into paths (`lovdata.no/…/kap2/%C2%A710`). "Accepted false positive" reads
differently as "URLs in your own language grade above LOW". Our two zero-measuring
corpora were both English; that was a sampling bias invisible from inside.
Two sub-classes worth separating: `{tenant}`/`{agent-id}` template placeholders in
API code samples encode to `%7B`/`%7D` (26 of that corpus's 36 hits — an artifact of
harvesting, not of prose), and a slugger deriving filenames from titles would
produce escapes *systematically* rather than incidentally — still unmeasured.
- **A percent-escaped path also defeats tokenization, which feeds the entropy
branch.** `%` is not in the separator class, so `NSMs%20Grunnprinsipper%20for%20IKT`
is one 34-character token where the same title with literal spaces would be four
short ones. Measured at H=4.04 — under the 4.4 floor, and moot in practice because
the `%` rule already disqualifies the URL. It is recorded because it means the
length floor does less work in non-ASCII paths than the calibration assumed.
- **Legitimate CDN content-asset ids trip the hex branch, permanently.** A ≥32-char
hex path segment is opaque by design, and several public CMSes mint asset URLs that
way (measured: 9 distinct on `regjeringen.no`, `ks.no`, `datatilsynet.no`). This is
the "legitimate build hash or doc id" case the calibration predicted, now confirmed
present in the field. The class does not decay — it is how those systems generate
URLs — so it is a standing false positive rather than a transient one. The branch is
otherwise precise (no other false positives in 2401 distinct URLs) and stays.
- **A non-empty query is graded as data-carrying — the over-block that actually
occurs in the field.** Three corpora have now measured it, and each found a
*disjoint* benign population:
**(1)** 16 of 16 query-carrying external URLs in a vendor-docs corpus were
publisher-authored campaign tracking (`utm_*` on the publisher's own domains);
**(2)** 28 of 28 in a capture store were content identity (`?v=`, `?channel_id=`,
`?all=true`) where the parameter *is* the resource; **(3)** 149 of 1694 distinct
`learn.microsoft.com` URLs in a reference corpus carried `?view=`, Microsoft Learn's
own documentation-version selector, plus 23 `?api-version=`. **No parameter-level
remedy covers any two of them, let alone all three:** an allowlist keyed on
tracking-parameter names resolves (1) entirely and (2) and (3) not at all; stripping
the query is lossless for (1), dereferences nothing for (2), and silently changes
*which document is cited* for (3) — the worst failure mode of the three, because the
result stays plausible. This is a settled constraint on any future middle tier, not
a hypothesis. The cost is bounded — a
query-carrying *link* is MEDIUM, so it disposes QUARANTINE_REVIEW under
`PRESET_USER_UPLOAD` and WARN under `PRESET_TRUSTED_SOURCE`: held or warned, never
hard-failed (pinned in `tests/test_wiring.py`, because both consumers inferred a
hard block rather than running it). An *image* keeps HIGH, and that is the carrier
where this would bite — the two corpora that reported a carrier breakdown contained
zero remote images and the third did not report one, so the image row of this
limitation remains unmeasured in the field.
- **Raw HTML with a *relative* URL attribute is HIGH, though it can reach no
attacker-controlled host.** The markdown paths test for an external target before
flagging; the raw-HTML path deliberately does not, because an active element needs
no URL at all (an `on*=` handler executes on its own). That reasoning covers event
handlers but over-reaches on the URL-attribute branch: an element outside the active
name set carrying `href="/en/agent-sdk/quickstart"` — an internal doc route — grades
HIGH. Measured on a vendor-docs corpus, where it lands on MDX components:
`<Card href="/…">` fires this way, and `<Frame>` fires on the *name* branch alone
because names are lower-cased and `frame` is in the active set — legacy HTML
framesets, which appear in essentially no modern documentation, while `Frame` is a
common component name. Case is not an available discriminator: HTML is
case-insensitive, so PascalCase cannot be treated as "component, not tag".
- **Raw-HTML findings count end tags.** `</a>` is active by name on its own, so a
corpus census that counts only opening tags understates what this detector reports
by roughly the ratio of closing to opening active tags (measured at 1.6× on one
corpus). Severity and finding count are unaffected — the class collapses to one
finding — but the `count` field is not a document count.
- **URL fragments are not graded.** A fragment is never sent to the server, so it
cannot carry data to the host a renderer auto-fetches, and `…/overview#section` is
the most common shape in real documentation. The residual: a *clicked* link to an
attacker-controlled page can have its `location.hash` read by that page's script,
so a fragment payload on a link (not an image) is uncovered.
- **Secret egress: base64-wrapped is caught, hex-wrapped is not.** The output gate
decodes base64 blobs and re-scans the plaintext, so a base64-*wrapped* secret
surfaces as `decoded:egress:*`. `entropy` exposes decoded plaintext for base64
only, so hex (and other encodings, or nested wraps) is a deliberate boundary —
decode the transport layer first if you need it scanned.
## The six documented gaps (tracked by the coverage matrix)
These are asserted to *still hold* by `tests/test_coverage_matrix.py` — a closed gap
fails the test, forcing this doc to be updated:
1. **Hex-wrapped secret egress**`entropy` decodes base64 only (above).
2. **Semantic / factual poisoning** — invisible to token analysis (above).
3. **A lone HIGH in trusted prose → WARN** — the §4.7 trust-scaling design (above).
4. **Lexicon dedup (`count=1`)** — first offset only, by design (above).
5. **Pure beaconing** — a bare-path remote image on a hostile host is LOW (above).
6. **Short opaque URL segment (<24 chars)** — below what entropy can resolve (above).
## Out-of-scope (documented boundary)
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
steganography; query-time / runtime guardrails; semantic factuality verification.