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.
This commit is contained in:
parent
956c835d38
commit
684ce3a45f
6 changed files with 383 additions and 18 deletions
|
|
@ -215,8 +215,9 @@ a green scan means safe content. The highest-impact items:
|
||||||
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`),
|
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`),
|
||||||
pure beaconing, and short opaque URL segments.
|
pure beaconing, and short opaque URL segments.
|
||||||
|
|
||||||
**Full list — 18 items, each with the mechanism, plus the out-of-scope boundary:**
|
**Full list — 26 items, each with the mechanism, plus the out-of-scope boundary:**
|
||||||
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md).
|
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
|
||||||
|
consumer corpora, including the false positives the URL-shape rule actually produces.
|
||||||
|
|
||||||
## Out-of-scope (documented boundary)
|
## Out-of-scope (documented boundary)
|
||||||
|
|
||||||
|
|
@ -226,6 +227,10 @@ steganography; query-time / runtime guardrails; semantic factuality verification
|
||||||
## Design & threat model
|
## Design & threat model
|
||||||
|
|
||||||
- [Design brief](docs/BRIEF.md) — what this repo contains and why.
|
- [Design brief](docs/BRIEF.md) — what this repo contains and why.
|
||||||
|
- [URL-shape rule](docs/URL-SHAPE.md) — `is_ordinary_url` stated precisely enough to
|
||||||
|
reconstruct, with worked examples asserted against the implementation. **Read this
|
||||||
|
before reasoning about the rule from prose:** three consumers reconstructed it from
|
||||||
|
summaries and each produced a different wrong number on a real corpus.
|
||||||
- [Build plan](docs/PLAN.md) — module build order and the reuse map.
|
- [Build plan](docs/PLAN.md) — module build order and the reuse map.
|
||||||
- [Adoption brief](docs/ADOPTION-BRIEF.md) — wiring the guard into an OKF
|
- [Adoption brief](docs/ADOPTION-BRIEF.md) — wiring the guard into an OKF
|
||||||
second-brain / LLM wiki, and a checklist for *when* to include it.
|
second-brain / LLM wiki, and a checklist for *when* to include it.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,34 @@ items; this is the full list, each with the mechanism.
|
||||||
(an injection in a directory listing is caught) rather than path-rejecting the
|
(an injection in a directory listing is caught) rather than path-rejecting the
|
||||||
conformant bundle. A front-end materialising individual uploads keeps the opposite
|
conformant bundle. A front-end materialising individual uploads keeps the opposite
|
||||||
rule (`allow_reserved=False`): a reserved basename is a listing-shadow and refused.
|
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. 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.
|
||||||
|
- **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
|
- **A document that *describes* attacks is a false positive.** Content documenting
|
||||||
prompt-injection payloads (security notes, this project's own corpus) trips
|
prompt-injection payloads (security notes, this project's own corpus) trips
|
||||||
carrier-strip / fail-secure. At the text layer "*about* an attack" and "*carrying*
|
carrier-strip / fail-secure. At the text layer "*about* an attack" and "*carrying*
|
||||||
|
|
@ -92,27 +120,71 @@ items; this is the full list, each with the mechanism.
|
||||||
QUARANTINE_REVIEW / FAIL_SECURE on an untrusted upload. Obfuscated encoding is a
|
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;
|
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,
|
it is listed here because it is the same *class* of over-block that 0.3.1 fixed,
|
||||||
in a rarer shape. **Measured (2026-07-25, two independent consumer corpora):
|
in a rarer shape. **It is a non-ASCII-language tax, and that is the finding.**
|
||||||
zero occurrences** — 0 of 347 external URLs in a 527-document vendor-docs corpus,
|
Three consumer corpora measured it (2026-07-25/26). The two English ones found
|
||||||
0 of 81 in a real capture store. That *bounds* the shape as rare in linked content;
|
zero — 0 of 347 external URLs in a 527-document vendor-docs corpus, 0 of 81 in a
|
||||||
it does not retire it. A consumer deriving filenames from titles through a slugger
|
capture store. The third, a 389-file Norwegian/Microsoft reference corpus, found
|
||||||
produces `%20` systematically rather than incidentally, and that corpus is still
|
10 distinct real escapes and **every one of them Norwegian**: `%C3%B8` and
|
||||||
unmeasured — so the open question is narrower than it was, not closed.
|
`%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
|
- **A non-empty query is graded as data-carrying — the over-block that actually
|
||||||
occurs in the field.** The same two corpora found this rule firing on *disjoint*
|
occurs in the field.** Three corpora have now measured it, and each found a
|
||||||
benign populations: 16 of 16 query-carrying external URLs in the vendor-docs corpus
|
*disjoint* benign population:
|
||||||
were publisher-authored campaign tracking (`utm_*` on the publisher's own domains),
|
**(1)** 16 of 16 query-carrying external URLs in a vendor-docs corpus were
|
||||||
while 35 of 35 in the capture store were content identity (`?v=`, `?channel_id=`,
|
publisher-authored campaign tracking (`utm_*` on the publisher's own domains);
|
||||||
`?all=true`) where the parameter *is* the resource. **No parameter-level remedy
|
**(2)** 28 of 28 in a capture store were content identity (`?v=`, `?channel_id=`,
|
||||||
covers both:** an allowlist keyed on tracking-parameter names resolves the first
|
`?all=true`) where the parameter *is* the resource; **(3)** 149 of 1694 distinct
|
||||||
population entirely and the second not at all, and stripping the query is lossless
|
`learn.microsoft.com` URLs in a reference corpus carried `?view=`, Microsoft Learn's
|
||||||
for the first while dereferencing nothing for the second. The cost is bounded — a
|
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
|
query-carrying *link* is MEDIUM, so it disposes QUARANTINE_REVIEW under
|
||||||
`PRESET_USER_UPLOAD` and WARN under `PRESET_TRUSTED_SOURCE`: held or warned, never
|
`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-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
|
hard block rather than running it). An *image* keeps HIGH, and that is the carrier
|
||||||
where this would bite — neither corpus contained a single remote image, so the
|
where this would bite — the two corpora that reported a carrier breakdown contained
|
||||||
image row of this limitation remains unmeasured in the field.
|
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
|
- **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
|
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
|
the most common shape in real documentation. The residual: a *clicked* link to an
|
||||||
|
|
|
||||||
121
docs/URL-SHAPE.md
Normal file
121
docs/URL-SHAPE.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# The URL-shape rule, stated so it can be reconstructed
|
||||||
|
|
||||||
|
`is_ordinary_url` decides whether a URL merely *names* a remote document or can
|
||||||
|
*carry data outward*. Severity grades on that answer (0.3.1), so a consumer
|
||||||
|
reasoning about its own corpus needs the rule exactly, not approximately.
|
||||||
|
|
||||||
|
**This document exists because approximate statements of it failed three times in
|
||||||
|
two days.** Three independent consumers reconstructed the rule from prose we sent
|
||||||
|
in coordination messages, and each produced a wrong number in a different way —
|
||||||
|
see [Commonly reconstructed wrong](#commonly-reconstructed-wrong). The worked
|
||||||
|
examples below are asserted against the real implementation by
|
||||||
|
`tests/test_url_shape_doc.py`, so this file cannot drift from the code.
|
||||||
|
|
||||||
|
## The algorithm, in order
|
||||||
|
|
||||||
|
A URL is **ordinary** only if every step passes. Any failure means *carrying*.
|
||||||
|
|
||||||
|
1. **Scheme gate.** `^(?:https?://|//)` — case-insensitive. Only http(s) and
|
||||||
|
protocol-relative URLs have an ordinary form at all. Every other scheme
|
||||||
|
(`javascript:`, `data:`, `ftp:`, `file:`, …) is active or fetches out-of-band on
|
||||||
|
its own terms and never grades down.
|
||||||
|
2. **Parse.** `urlsplit(url)`. A `ValueError` (malformed authority — bad IPv6, bad
|
||||||
|
port) is *not ordinary*: unparseable means ungradable, and ungradable fails closed.
|
||||||
|
3. **Query and userinfo.** `parts.query`, `parts.username`, `parts.password` — any
|
||||||
|
non-empty value means carrying. A query can move a value to the host; userinfo
|
||||||
|
is a credential in the URL.
|
||||||
|
4. **Percent-escapes.** `"%" in (parts.netloc + parts.path)` means carrying.
|
||||||
|
5. **Opaque tokens.** Split `parts.netloc + parts.path` on the separator class and
|
||||||
|
test **every** token. Any opaque token means carrying.
|
||||||
|
|
||||||
|
The **fragment is deliberately excluded** from all of this. It is never sent to the
|
||||||
|
server, so it cannot carry data to a host that a renderer auto-fetches, and
|
||||||
|
`…/overview#prerequisites` is the single most common shape in real documentation.
|
||||||
|
|
||||||
|
### Step 5 in full — the part that is always missed
|
||||||
|
|
||||||
|
Tokenization happens **first**, and the entropy/base64/hex tests apply to each
|
||||||
|
resulting token, **never to the whole path or filename**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_URL_TOKEN_RE = re.compile(r"[/._\-~+,;:=&$!*'()]+")
|
||||||
|
tokens = [t for t in _URL_TOKEN_RE.split(parts.netloc + parts.path) if t]
|
||||||
|
```
|
||||||
|
|
||||||
|
Note what is in that character class: **`/` `.` `_` `-` `~` `+` `,` `;` `:` `=` `&`
|
||||||
|
`$` `!` `*` `'` `(` `)`**. Hyphens, underscores and dots are separators, so a long
|
||||||
|
hyphenated filename shatters into short tokens. Note also what is *not* in it:
|
||||||
|
`%` is not a separator, so a percent-escaped path yields *longer* tokens than the
|
||||||
|
same path with literal spaces.
|
||||||
|
|
||||||
|
A token is **opaque** if any of three branches fires — cheapest first:
|
||||||
|
|
||||||
|
| Branch | Floor | Test |
|
||||||
|
|---|---|---|
|
||||||
|
| base64 | **≥ 20 chars** | matches `[A-Za-z0-9+/]{20,}={0,3}` **and** b64-decodes to ≥80% printable text |
|
||||||
|
| hex | **≥ 32 chars** | matches `(?:0x)?[0-9a-fA-F]{32,}` |
|
||||||
|
| entropy | **≥ 24 chars** | Shannon entropy ≥ **4.4** |
|
||||||
|
|
||||||
|
Every branch has a length floor. Nothing shorter than 20 characters can ever be
|
||||||
|
opaque by any branch.
|
||||||
|
|
||||||
|
## Worked examples
|
||||||
|
|
||||||
|
Asserted against `is_ordinary_url` by `tests/test_url_shape_doc.py`. `ordinary`
|
||||||
|
means "names a document" (LOW); `carrying` keeps the carrier's full severity.
|
||||||
|
|
||||||
|
| URL | Verdict | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `https://learn.microsoft.com/en-us/azure/overview` | ordinary | bare path |
|
||||||
|
| `https://learn.microsoft.com/en-us/azure/overview#prerequisites` | ordinary | fragment excluded |
|
||||||
|
| `https://youtu.be/dQw4w9WgXcQ` | ordinary | 11 chars, under every floor |
|
||||||
|
| `https://www.bbc.com/news/articles/c8x9k2m1l0po` | ordinary | 12 chars, under every floor |
|
||||||
|
| `https://example.com/a/550e8400-e29b-41d4-a716-446655440000/doc` | ordinary | UUID splits on `-`; longest token 12 |
|
||||||
|
| `https://example.com/docs/CISPE-Buying-Cloud-Services-in-Public-Sector-Handbook-v2-FEB-2022_EN-Source_v2_Norwegian.pdf` | ordinary | 16 tokens, longest 9, worst H=3.17 |
|
||||||
|
| `https://ai.meta.com/blog/` | ordinary | `blog` is 4 chars — below the base64 floor |
|
||||||
|
| `https://www.youtube.com/watch?v=dQw4w9WgXcQ` | carrying | non-empty query |
|
||||||
|
| `https://claude.com/pricing?utm_source=docs` | carrying | non-empty query |
|
||||||
|
| `https://learn.microsoft.com/azure/ai/overview?view=azureml-api-2` | carrying | non-empty query |
|
||||||
|
| `https://nsm.no/NSMs%20Grunnprinsipper%20for%20IKT-sikkerhet.pdf` | carrying | percent-escapes |
|
||||||
|
| `https://lovdata.no/nav/lov/2025-06-20-81/kap2/%C2%A710` | carrying | percent-escapes |
|
||||||
|
| `https://token:s3cr3t@evil.test/p.png` | carrying | userinfo |
|
||||||
|
| `https://example.com/d41d8cd98f00b204e9800998ecf8427e` | carrying | 32-char hex token |
|
||||||
|
| `https://evil.test/c3RvbGVuIHNlc3Npb24gdG9rZW4gdmFsdWU/p.png` | carrying | base64 decoding to text |
|
||||||
|
| `https://c3RvbGVuIHNlc3Npb24gdG9rZW4gdmFsdWU.evil.test/p.png` | carrying | opaque host label |
|
||||||
|
| `ftp://example.com/pub/file.txt` | carrying | scheme gate |
|
||||||
|
| `javascript:alert(1)` | carrying | scheme gate |
|
||||||
|
|
||||||
|
## Commonly reconstructed wrong
|
||||||
|
|
||||||
|
Each of these produced a real, wrong number on a real corpus.
|
||||||
|
|
||||||
|
- **Omitting tokenization.** Computing Shannon entropy over the whole filename
|
||||||
|
scores `CISPE-Buying-…-Norwegian.pdf` at H=4.75 and concludes the 4.4 floor
|
||||||
|
over-blocks ordinary documents. Under the real tokenizer its worst token is
|
||||||
|
`Norwegian` at H=3.17 and the URL is ordinary. Entropy is **per token**.
|
||||||
|
- **Omitting the base64 length floor.** Testing "b64-decodes to printable text"
|
||||||
|
without `len ≥ 20` fires on ordinary path words — `blog`, `digi` and `1544`
|
||||||
|
decode to printable bytes, while `news` and `docs` do not, so the reconstruction
|
||||||
|
appears to discriminate on something meaningful and does not.
|
||||||
|
- **Omitting step 5 entirely.** Applying only "query or percent-escape" is
|
||||||
|
*strictly weaker* than the real rule, so it undercounts carrying URLs. It can
|
||||||
|
never overcount: every URL it calls carrying really is.
|
||||||
|
|
||||||
|
## Grading, once the shape is known
|
||||||
|
|
||||||
|
Shape decides *severity*, not disposition. Per-construct severities live in
|
||||||
|
`calibration.py`; an ordinary URL grades `ACTIVE_CONTENT_ORDINARY_SEVERITY` (LOW).
|
||||||
|
|
||||||
|
| Carrier | Ordinary URL | Carrying URL |
|
||||||
|
|---|---|---|
|
||||||
|
| markdown link, autolink, refdef | LOW | MEDIUM |
|
||||||
|
| markdown image, raw HTML, `data:` URI | LOW | HIGH |
|
||||||
|
|
||||||
|
Disposition then applies trust: under `PRESET_USER_UPLOAD` a MEDIUM reaches
|
||||||
|
QUARANTINE_REVIEW and a HIGH fails secure; under `PRESET_TRUSTED_SOURCE` both warn.
|
||||||
|
**MEDIUM never hard-fails under either preset** — a query-carrying *link* is held or
|
||||||
|
warned, never blocked. Two consumers inferred otherwise; it is pinned in
|
||||||
|
`tests/test_wiring.py`.
|
||||||
|
|
||||||
|
Known over-blocks measured against real corpora are listed in
|
||||||
|
[`LIMITATIONS.md`](LIMITATIONS.md).
|
||||||
|
|
@ -259,3 +259,38 @@ def test_default_source_is_output_and_override_respected():
|
||||||
for f in scan_active_content(_ECHOLEAK).findings)
|
for f in scan_active_content(_ECHOLEAK).findings)
|
||||||
assert all(f.source is Source.INPUT
|
assert all(f.source is Source.INPUT
|
||||||
for f in scan_active_content(_ECHOLEAK, source=Source.INPUT).findings)
|
for f in scan_active_content(_ECHOLEAK, source=Source.INPUT).findings)
|
||||||
|
|
||||||
|
|
||||||
|
# --- raw-HTML over-blocks measured on a vendor-docs corpus (2026-07-26) -------
|
||||||
|
# Documented in docs/LIMITATIONS.md. Pinned so the concessions stay honest: a
|
||||||
|
# closed over-block should fail here and force the doc to be updated.
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cid,text", [
|
||||||
|
# Fires on the URL-attr branch although the target is a relative doc route,
|
||||||
|
# which cannot reach an attacker-controlled host. `Card` is not in the active
|
||||||
|
# name set — the href alone carries it.
|
||||||
|
("relative-href-on-inactive-name",
|
||||||
|
'<Card title="Quickstart" icon="play" href="/en/agent-sdk/quickstart">'),
|
||||||
|
# Fires on the *name* branch: names are lower-cased and `frame` is in the
|
||||||
|
# active set (legacy HTML framesets), while `Frame` is a common MDX component.
|
||||||
|
("mdx-component-named-like-a-tag", "<Frame>"),
|
||||||
|
])
|
||||||
|
def test_raw_html_overblocks_are_still_high(cid, text):
|
||||||
|
finding = [f for f in scan_active_content(text).findings
|
||||||
|
if f.label == "active:raw-html"]
|
||||||
|
assert len(finding) == 1, f"{cid}: raw-html not reported"
|
||||||
|
assert finding[0].severity is Severity.HIGH, f"{cid}: {finding[0].severity}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_html_counts_end_tags():
|
||||||
|
# `</a>` is active by name on its own, so a corpus census counting only opening
|
||||||
|
# tags understates this detector's `count`. The class still collapses to ONE
|
||||||
|
# finding — the count is what moves.
|
||||||
|
solo = [f for f in scan_active_content("</a>").findings
|
||||||
|
if f.label == "active:raw-html"]
|
||||||
|
assert len(solo) == 1 and solo[0].count == 1
|
||||||
|
|
||||||
|
pair = [f for f in scan_active_content('<a href="https://x.example/p">t</a>').findings
|
||||||
|
if f.label == "active:raw-html"]
|
||||||
|
assert len(pair) == 1, "a start/end pair must not split into two findings"
|
||||||
|
assert pair[0].count == 2, f"end tag not counted: {pair[0].count}"
|
||||||
|
|
|
||||||
|
|
@ -516,3 +516,69 @@ def test_okf_adapter_is_exposed_from_package():
|
||||||
|
|
||||||
assert "okf" in guard.__all__
|
assert "okf" in guard.__all__
|
||||||
assert guard.okf.import_bundle is import_bundle
|
assert guard.okf.import_bundle is import_bundle
|
||||||
|
|
||||||
|
|
||||||
|
# --- v0.2 frontmatter reach: what the restricted grammar admits (2026-07-26) ---
|
||||||
|
# Measured for a consumer planning an additive OKF v0.2 profile. Documented in
|
||||||
|
# docs/LIMITATIONS.md; pinned here so the compatibility wall cannot move silently.
|
||||||
|
|
||||||
|
_V02_REJECTED = [
|
||||||
|
("generated (nested)", "generated:\n at: 2026-07-26T10:00:00Z\n"),
|
||||||
|
("executor (nested)", "executor:\n resource: skills/run-on-bq.md\n"),
|
||||||
|
("attester (nested)", "attester:\n resource: attesters/sql_equality.py\n"),
|
||||||
|
("sources (block list of mappings)",
|
||||||
|
"sources:\n - uri: https://e.com/a\n kind: doc\n"),
|
||||||
|
("flow sequence", "tags: [a, b, c]\n"),
|
||||||
|
("flow mapping", "executor: {resource: skills/run.md}\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cid,fm", _V02_REJECTED, ids=[c[0] for c in _V02_REJECTED])
|
||||||
|
def test_v02_nested_and_flow_frontmatter_hard_rejects(cid, fm):
|
||||||
|
# Both of v0.2's backward-breaking migration targets (`generated.at`, `sources`)
|
||||||
|
# are on this list, so a conformant v0.2 concept cannot pass the gate at all.
|
||||||
|
with pytest.raises(OKFFrontmatterError):
|
||||||
|
parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")
|
||||||
|
|
||||||
|
|
||||||
|
_V02_ADMITTED = [
|
||||||
|
("runtime", "runtime: bigquery\n"),
|
||||||
|
("computation path", "computation: computations/gm.sql\n"),
|
||||||
|
("status/stale_after", "status: active\nstale_after: 2026-12-01\n"),
|
||||||
|
("verified bool", "verified: true\n"),
|
||||||
|
("block sequence of scalars", "tags:\n - alpha\n - beta\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cid,fm", _V02_ADMITTED, ids=[c[0] for c in _V02_ADMITTED])
|
||||||
|
def test_v02_flat_frontmatter_still_parses(cid, fm):
|
||||||
|
assert parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")[0]["id"] == "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_key_block_sequence_item_is_misparsed_as_a_string():
|
||||||
|
# The documented defect: two keys per item hard-reject (loud, safe), but ONE key
|
||||||
|
# parses "successfully" into the wrong type. A consumer reading
|
||||||
|
# frontmatter["sources"][0].get("uri") gets a string, not a mapping.
|
||||||
|
fm, _ = parse_frontmatter(
|
||||||
|
"---\nid: x\nsources:\n - uri: https://e.com/a\n---\n\nbody\n"
|
||||||
|
)
|
||||||
|
assert fm["sources"] == ["uri: https://e.com/a"], "shape changed — update LIMITATIONS.md"
|
||||||
|
assert not isinstance(fm["sources"][0], dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_resource_pointer_fails_the_allowlist():
|
||||||
|
# A top-level `resource` naming executable code is caught by the https allowlist.
|
||||||
|
for pointer in ("attesters/sql_equality.py", "skills/run-on-bq.md"):
|
||||||
|
with pytest.raises(OKFResourceError):
|
||||||
|
validate_resource_url(pointer)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pointer_in_one_key_sequence_reaches_the_consumer_tree():
|
||||||
|
# The security-relevant consequence of the misparse above: the pointer never
|
||||||
|
# touches the top-level `resource` key, so the https allowlist never inspects it
|
||||||
|
# and door C admits the concept. Not conformant OKF — a well-formed bundle will
|
||||||
|
# not produce this shape — but mode-b writes the merged concept verbatim.
|
||||||
|
doc = ("---\nid: x\ntype: Attested Computation\n"
|
||||||
|
"attester:\n - resource: attesters/sql_equality.py\n---\n\nbody\n")
|
||||||
|
result = import_bundle({"computations/x.md": doc})
|
||||||
|
assert result.disposition is Disposition.WARN, "hole closed — update LIMITATIONS.md"
|
||||||
|
|
|
||||||
66
tests/test_url_shape_doc.py
Normal file
66
tests/test_url_shape_doc.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""The URL-shape doc is executable: every worked example is asserted against code.
|
||||||
|
|
||||||
|
`docs/URL-SHAPE.md` exists because three consumers reconstructed `is_ordinary_url`
|
||||||
|
from prose and each got a different wrong answer. A reference that can drift from
|
||||||
|
the implementation would reproduce exactly that failure, so the worked-examples
|
||||||
|
table is parsed out of the document and run through the real predicate here.
|
||||||
|
|
||||||
|
A row that disagrees with the code fails this test — whichever of the two is wrong.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from llm_ingestion_guard.active_content import is_ordinary_url
|
||||||
|
from llm_ingestion_guard.entropy import is_base64_like, is_hex_blob
|
||||||
|
from llm_ingestion_guard import calibration as cal
|
||||||
|
|
||||||
|
_DOC = Path(__file__).resolve().parent.parent / "docs" / "URL-SHAPE.md"
|
||||||
|
|
||||||
|
# `| `<url>` | <verdict> | <why> |` — the verdict column is the assertion.
|
||||||
|
_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(ordinary|carrying)\s*\|", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows() -> list[tuple[str, bool]]:
|
||||||
|
text = _DOC.read_text(encoding="utf-8")
|
||||||
|
return [(url, verdict == "ordinary") for url, verdict in _ROW_RE.findall(text)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_exists_and_table_was_actually_parsed():
|
||||||
|
# Without this floor a renamed heading or reformatted table would empty the
|
||||||
|
# parametrize list and turn the whole file into a silent pass.
|
||||||
|
rows = _rows()
|
||||||
|
assert len(rows) >= 15, f"only parsed {len(rows)} worked examples from {_DOC.name}"
|
||||||
|
assert any(ordinary for _, ordinary in rows), "no ordinary examples parsed"
|
||||||
|
assert any(not ordinary for _, ordinary in rows), "no carrying examples parsed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,expected_ordinary", _rows(), ids=[u for u, _ in _rows()])
|
||||||
|
def test_worked_example_matches_implementation(url, expected_ordinary):
|
||||||
|
assert is_ordinary_url(url) is expected_ordinary
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_floors_match_calibration():
|
||||||
|
# The floors table in the doc states three numbers. They are the reason every
|
||||||
|
# reconstruction that omitted them over-fired, so they are pinned to source.
|
||||||
|
text = _DOC.read_text(encoding="utf-8")
|
||||||
|
assert "≥ 20 chars" in text and "≥ 32 chars" in text and "≥ 24 chars" in text
|
||||||
|
assert f"≥ **{cal.URL_OPAQUE_ENTROPY_H}**" in text
|
||||||
|
assert cal.URL_OPAQUE_MIN_LEN == 24 and cal.URL_OPAQUE_HEX_MIN_LEN == 32
|
||||||
|
# The base64 floor lives in `entropy`, not `calibration` — assert behaviourally.
|
||||||
|
assert is_base64_like("A" * 20) and not is_base64_like("A" * 19)
|
||||||
|
assert is_hex_blob("a" * 32) and not is_hex_blob("a" * 31)
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_separator_class_matches_the_tokenizer():
|
||||||
|
# The doc spells out the separator characters because omitting tokenization was
|
||||||
|
# the error that produced the largest wrong number. Keep the two in step.
|
||||||
|
from llm_ingestion_guard.active_content import _URL_TOKEN_RE
|
||||||
|
|
||||||
|
text = _DOC.read_text(encoding="utf-8")
|
||||||
|
assert _URL_TOKEN_RE.pattern in text, "tokenizer regex not quoted verbatim in the doc"
|
||||||
|
for sep in "/._-~+,;:=&$!*'()":
|
||||||
|
assert _URL_TOKEN_RE.split(f"a{sep}b") == ["a", "b"], sep
|
||||||
Loading…
Add table
Add a link
Reference in a new issue