Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0184df9ed9 | |||
| 9aeceb02c0 | |||
| 58704834b6 | |||
| 7cb4553301 | |||
| 01f6f382c4 | |||
| 74656123f9 | |||
| 4472f209a4 | |||
| 2b03b8d643 | |||
| 246e1fc1d3 | |||
| 45c2b06315 | |||
| 869f9058f7 |
15 changed files with 779 additions and 82 deletions
74
CHANGELOG.md
74
CHANGELOG.md
|
|
@ -5,9 +5,67 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.2.0] — 2026-08-23
|
||||||
|
|
||||||
Nothing yet.
|
### Added — OKF frontmatter can express one mapping form: typed and allowlisted
|
||||||
|
|
||||||
|
`okf.parse_frontmatter` gave the mapping *class* no expressible form at all. OKF
|
||||||
|
v0.2 writes its whole trust and provenance layer as mappings — SPEC.md @
|
||||||
|
`62432a09` uses flow form in its own §5.1/§5.2 examples, and §11 carries a hard
|
||||||
|
MUST for consumers ("MUST treat a bare `verified` mapping as a one-element
|
||||||
|
list") that presupposes they parse. A consumer measured **0 of 53** upstream
|
||||||
|
concepts through the gate on 0.3.4, 1.0.0 and 1.1.0. That was a contract
|
||||||
|
collision, not a calibration setting: no threshold would have moved it.
|
||||||
|
|
||||||
|
Admitted now: a flow mapping (`generated: { by: x, at: y }`), as a value or as a
|
||||||
|
block-list item, whose every key is on a nine-name allowlist (`by`, `at`, `from`,
|
||||||
|
`to`, `id`, `title`, `author`, `usage_count`, `last_modified`) and whose every
|
||||||
|
leaf is a plain scalar run through the *unchanged* dangerous-value and
|
||||||
|
mapping-construct predicates.
|
||||||
|
|
||||||
|
The form is additive and refusal stays the default. A key off the allowlist, a
|
||||||
|
nested collection, a quoted leaf, a duplicate key, an empty or unclosed mapping,
|
||||||
|
and `{a:b}` (which PyYAML 6.0.3 reads as the *key* `a:b`) all raise, and a
|
||||||
|
refused mapping still raises rather than degrading into a string — the 1.1.0
|
||||||
|
defect is not reopened. Nested-block (`k:\n sub: v`), dotted (`k.sub: v`) and
|
||||||
|
inline-second-colon (`k: sub: v`) routes to a mapping still raise, each on its
|
||||||
|
own rule.
|
||||||
|
|
||||||
|
**`resource` is deliberately off the allowlist**, though SPEC.md §5.1 names it
|
||||||
|
inside a `sources` entry. It is a pointer rather than a label and the only key
|
||||||
|
T3 exists for: admitting it would let `executor: { resource: skills/run.md }`
|
||||||
|
carry an executable-code pointer through in typed clothes, which is the door-C
|
||||||
|
route closed in 1.1.0. It costs nothing today — the conformant carrier for
|
||||||
|
`sources[].resource` is a block sequence of block mappings, which this form does
|
||||||
|
not admit either way.
|
||||||
|
|
||||||
|
Mapping leaves are scanned like every other frontmatter value (T1), so an
|
||||||
|
injection parked in `generated: { by: ... }` reaches `scan_output`. Coverage
|
||||||
|
matrix: 130 classes, up from 129 (the new row is the off-allowlist key).
|
||||||
|
|
||||||
|
No exported surface changed; no detector behaviour and no calibration changed.
|
||||||
|
|
||||||
|
|
||||||
|
### Changed — the ReDoS sweep now measures on the same clock as the bounds it justifies
|
||||||
|
|
||||||
|
`docs/redos-sweep.py` timed on `time.monotonic()` while every ReDoS bound in the
|
||||||
|
suite moved to process CPU time (`tests/redos_clock.py`), so the 1.5 ms
|
||||||
|
sensitivity floor and the "~23 s at the cap" figure published in
|
||||||
|
`docs/LIMITATIONS.md` came from a different instrument than the bounds they
|
||||||
|
support. The script now imports `scan_seconds` rather than timing itself.
|
||||||
|
|
||||||
|
The floor was re-derived on that instrument and **stayed at 1.5 ms**: over twelve
|
||||||
|
full runs of all 2585 arms the median ratio is 1.95-2.03 in every size bucket
|
||||||
|
above 50 µs, but two-point excursions past the 2.6 flag threshold persist at every
|
||||||
|
magnitude (p99 ratio 2.9-3.3 even above 1 ms) — 6.9 flagged arms per run at a
|
||||||
|
0.5 ms floor, 1.1 at 1.0 ms, 0.33 at 1.5 ms. Descheduling was never what made this
|
||||||
|
sweep noisy; a ratio computed from two points is. Four arms flagged across those
|
||||||
|
twelve runs, each in exactly one of them, and six arms that have ever flagged
|
||||||
|
re-measure at exponent 0.97-1.09 over six doublings — at most 1.2 s at the
|
||||||
|
1 000 000-char cap. The pattern count the script prints is 152, not the 150 of the
|
||||||
|
0.3.4 entry below; `docs/LIMITATIONS.md` now carries the current number.
|
||||||
|
|
||||||
|
No exported surface, no detector behaviour and no calibration changed.
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] — 2026-08-13
|
## [1.1.0] — 2026-08-13
|
||||||
|
|
@ -33,6 +91,12 @@ while closing it**, and is the reason this release names two routes rather than
|
||||||
Neither shape is conformant OKF — a well-formed bundle does not produce them; a
|
Neither shape is conformant OKF — a well-formed bundle does not produce them; a
|
||||||
malformed or hostile one can.
|
malformed or hostile one can.
|
||||||
|
|
||||||
|
**What closed is the type confusion, not pointer-smuggling as a class.** T3 still
|
||||||
|
inspects `resource` and nothing else, so an honest string under another key rides
|
||||||
|
through exactly as before: `attester: attesters/sql_equality.py` is WARN, while
|
||||||
|
the same path under `resource:` FAIL_SECUREs. The string is still scanned like any
|
||||||
|
other frontmatter value under T1. Nothing about that changed here.
|
||||||
|
|
||||||
**The boundary is where YAML puts it**, ground-truthed against PyYAML 6.0.3 rather
|
**The boundary is where YAML puts it**, ground-truthed against PyYAML 6.0.3 rather
|
||||||
than reasoned: `": "` and a trailing `":"` are exactly the two shapes where a plain
|
than reasoned: `": "` and a trailing `":"` are exactly the two shapes where a plain
|
||||||
scalar becomes a mapping, and they are refused. A colon carrying neither a space nor
|
scalar becomes a mapping, and they are refused. A colon carrying neither a space nor
|
||||||
|
|
@ -46,8 +110,10 @@ here — the `1.0.0` entry says exactly this is a fix, not a break. A consumer w
|
||||||
bundles carry an unquoted `": "` in a frontmatter value will now see those concepts
|
bundles carry an unquoted `": "` in a frontmatter value will now see those concepts
|
||||||
refused at import; quote the value, and it parses.
|
refused at import; quote the value, and it parses.
|
||||||
|
|
||||||
Suite 792 → **802** (11 rows added, 1 retired). 129/129 classes, 6/6 documented
|
Suite 792 → **802**: 13 rows added (4 rejected shapes, 7 admitted ones, 2 through
|
||||||
gaps, 35 limitations — all unchanged.
|
`import_bundle`), 3 retired (the two that pinned the defect, and the one-key row
|
||||||
|
in the block-list table). 129/129 classes, 6/6 documented gaps, 35 limitations —
|
||||||
|
all unchanged.
|
||||||
|
|
||||||
|
|
||||||
## [1.0.0] — 2026-08-13
|
## [1.0.0] — 2026-08-13
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -11,11 +11,18 @@ framework-agnostisk kode.
|
||||||
Referanse-implementasjon: `claude-code-llm-wiki` Stage B (`tools/wiki_ingest/`).
|
Referanse-implementasjon: `claude-code-llm-wiki` Stage B (`tools/wiki_ingest/`).
|
||||||
Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen.
|
Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen.
|
||||||
|
|
||||||
Repoet er på **v1.1.0** — den eksporterte Python-surfacen er frosset under semver
|
Repoet er på **v1.2.0** — den eksporterte Python-surfacen er frosset under semver
|
||||||
(deteksjonsatferd er det IKKE; kalibrering flytter seg i 1.x). Stdlib-kjernen er
|
(deteksjonsatferd er det IKKE; kalibrering flytter seg i 1.x). Stdlib-kjernen er
|
||||||
bygget og testet (15 moduler +
|
bygget og testet (15 moduler +
|
||||||
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
|
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
|
||||||
detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
|
detektor (EchoLeak-klassen) i output-gaten. OKF-frontmatterens mapping-klasse
|
||||||
|
har **én** uttrykkbar form (G3, 21.08): en flow-mapping (`generated: { by: x, at: y }`) — som verdi eller som blokkliste-
|
||||||
|
element — der HVER nøkkel står på en ni-navns allowlist og hvert blad er en ren
|
||||||
|
skalar. Formen er trygg fordi allowlisten inspiserer hver nøkkel; det blanke
|
||||||
|
avslaget var håndhevelsen, ikke poenget. `resource` er bevisst UTE av
|
||||||
|
allowlisten (peker, ikke etikett — den ene nøkkelen T3 finnes for). Blokk-,
|
||||||
|
dotted- og inline-kolon-rutene raiser fortsatt, og en avvist mapping raiser —
|
||||||
|
den degraderer aldri til en streng (1.1.0-defekten). Mode-b `import_bundle` skanner
|
||||||
reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å
|
reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å
|
||||||
path-avvise dem; upload-front-end beholder shadow-reject (`allow_reserved=False`).
|
path-avvise dem; upload-front-end beholder shadow-reject (`allow_reserved=False`).
|
||||||
Output-gatens decode-and-rescan mater dekodet base64-klartekst gjennom BÅDE lexicon
|
Output-gatens decode-and-rescan mater dekodet base64-klartekst gjennom BÅDE lexicon
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure.
|
Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
@ -33,7 +33,7 @@ at write time, never assumed from the format. Any pipeline ingesting external da
|
||||||
into an agent-read store has this shape; an OKF wiki is its canonical form — which
|
into an agent-read store has this shape; an OKF wiki is its canonical form — which
|
||||||
is why the guard ships a first-class OKF adapter (below).
|
is why the guard ships a first-class OKF adapter (below).
|
||||||
|
|
||||||
**Status:** `v1.1.0`. The stdlib-only core — its detector, contract, and
|
**Status:** `v1.2.0`. The stdlib-only core — its detector, contract, and
|
||||||
OKF-adapter modules plus the top-level wiring — is built and tested, exercised by
|
OKF-adapter modules plus the top-level wiring — is built and tested, exercised by
|
||||||
an end-to-end showcase and adversarial + false-positive corpora. The exported
|
an end-to-end showcase and adversarial + false-positive corpora. The exported
|
||||||
Python surface is now frozen under semver: nothing exported is removed, renamed or
|
Python surface is now frozen under semver: nothing exported is removed, renamed or
|
||||||
|
|
@ -41,12 +41,24 @@ given a different meaning without a `2.0.0`. **Detection behaviour is not frozen
|
||||||
severities, thresholds and lexicon entries are calibration and move in `1.x`. There
|
severities, thresholds and lexicon entries are calibration and move in `1.x`. There
|
||||||
are real limitations, stated plainly below; read them.
|
are real limitations, stated plainly below; read them.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Install](#install)
|
||||||
|
- [Quickstart — the two bookends](#quickstart--the-two-bookends)
|
||||||
|
- [OKF / LLM-wiki support (shipped)](#okf--llm-wiki-support-shipped)
|
||||||
|
- [What it protects against](#what-it-protects-against)
|
||||||
|
- [The reusable contract (adopt-this checklist)](#the-reusable-contract-adopt-this-checklist)
|
||||||
|
- [Known limitations](#known-limitations)
|
||||||
|
- [Non-goals](#non-goals)
|
||||||
|
- [Design & threat model](#design--threat-model)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag:
|
Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v1.1.0"
|
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v1.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or
|
The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or
|
||||||
|
|
@ -153,7 +165,15 @@ Per-concept gates: **path / reserved-name** (rejects `..` traversal and reserved
|
||||||
reject-by-default loader that refuses anchors, aliases, and explicit tags *by
|
reject-by-default loader that refuses anchors, aliases, and explicit tags *by
|
||||||
construction*, so a billion-laughs alias expansion or a `!!python/object` coercion
|
construction*, so a billion-laughs alias expansion or a `!!python/object` coercion
|
||||||
cannot occur (it is deliberately **not** a general YAML engine, whose own features
|
cannot occur (it is deliberately **not** a general YAML engine, whose own features
|
||||||
are the attack surface); **`resource` https-allowlist** (hard-rejects
|
are the attack surface). The one mapping form it accepts is OKF v0.2's flow
|
||||||
|
mapping — `generated: { by: x, at: y }`, `verified: { … }` bare or listed,
|
||||||
|
`usage_window: { from: …, to: … }` — admitted key-by-key against a nine-name
|
||||||
|
allowlist (`by`, `at`, `from`, `to`, `id`, `title`, `author`, `usage_count`,
|
||||||
|
`last_modified`) with plain-scalar leaves only. A key off that list, a nested
|
||||||
|
collection or a duplicate key is refused, and `resource` is deliberately not on
|
||||||
|
it; the block, dotted and inline-colon routes to a mapping still raise. See
|
||||||
|
[LIMITATIONS](docs/LIMITATIONS.md) for what that admits and what it still walls
|
||||||
|
off (a `sources` block list of mappings is still refused); **`resource` https-allowlist** (hard-rejects
|
||||||
`data:`/`javascript:`/`file:` before commit — a reject-gate, not defang);
|
`data:`/`javascript:`/`file:` before commit — a reject-gate, not defang);
|
||||||
**whole-concept scan** (frontmatter *values* + body through `scan_output`);
|
**whole-concept scan** (frontmatter *values* + body through `scan_output`);
|
||||||
**cross-link graph** (surfaces dangling targets, the dormant-injection signal, and
|
**cross-link graph** (surfaces dangling targets, the dormant-injection signal, and
|
||||||
|
|
@ -167,7 +187,7 @@ driven by a **live payload** in the coverage matrix — run it to watch all 134
|
||||||
in your own environment:
|
in your own environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m llm_ingestion_guard.coverage # 129/129 classes; exit 0 = all as documented
|
python -m llm_ingestion_guard.coverage # 130/130 classes; exit 0 = all as documented
|
||||||
```
|
```
|
||||||
|
|
||||||
| Anchor | Attack classes it stops (representative) |
|
| Anchor | Attack classes it stops (representative) |
|
||||||
|
|
@ -249,7 +269,7 @@ a green scan means safe content. The highest-impact items:
|
||||||
two of the three corpora are living, so the cells are not rewritten in place.
|
two of the three corpora are living, so the cells are not rewritten in place.
|
||||||
Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py).
|
Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py).
|
||||||
|
|
||||||
**Full list — 35 items, each with the mechanism, plus the out-of-scope boundary:**
|
**Full list — 44 items, each with the mechanism, plus the out-of-scope boundary:**
|
||||||
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
|
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
|
||||||
consumer corpora, including the false positives the URL-shape rule actually produces.
|
consumer corpora, including the false positives the URL-shape rule actually produces.
|
||||||
|
|
||||||
|
|
|
||||||
10
SECURITY.md
10
SECURITY.md
|
|
@ -25,12 +25,12 @@ you happened to observe.
|
||||||
**Do not open a public issue for a vulnerability.** Public disclosure before a fix
|
**Do not open a public issue for a vulnerability.** Public disclosure before a fix
|
||||||
gives an attacker a window against every downstream consumer.
|
gives an attacker a window against every downstream consumer.
|
||||||
|
|
||||||
Instead, report it **privately** to the maintainer via the canonical repository on
|
Instead, report it privately to <security@fromaitochitta.com> — mark the subject
|
||||||
Forgejo:
|
`SECURITY`.
|
||||||
|
|
||||||
- Repository: `git.fromaitochitta.com/open/llm-ingestion-pipeline-security`
|
- Canonical repository: https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security
|
||||||
- Contact the maintainer directly through that Forgejo instance (private message /
|
- Alternatively, contact the maintainer directly through that Forgejo instance
|
||||||
maintainer contact) and mark the subject `SECURITY`.
|
(private message / maintainer contact) and mark the subject `SECURITY`.
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
especially one converging on Google's Open Knowledge Format (OKF v0.1) — and needs
|
especially one converging on Google's Open Knowledge Format (OKF v0.1) — and needs
|
||||||
to decide **when** and **where** to add a write-time ingestion guard.
|
to decide **when** and **where** to add a write-time ingestion guard.
|
||||||
|
|
||||||
**Status of the guard:** `v1.1.0`. Stdlib-only core, framework-agnostic. The
|
**Status of the guard:** `v1.2.0`. Stdlib-only core, framework-agnostic. The
|
||||||
exported Python surface is frozen under semver — nothing exported is removed,
|
exported Python surface is frozen under semver — nothing exported is removed,
|
||||||
renamed or given a different meaning without a `2.0.0`. Detection behaviour is
|
renamed or given a different meaning without a `2.0.0`. Detection behaviour is
|
||||||
*not* frozen: severities, thresholds and lexicon entries are calibration and move
|
*not* frozen: severities, thresholds and lexicon entries are calibration and move
|
||||||
|
|
@ -142,9 +142,9 @@ live payload:
|
||||||
python -m llm_ingestion_guard.coverage # exit 0 = all as documented
|
python -m llm_ingestion_guard.coverage # exit 0 = all as documented
|
||||||
```
|
```
|
||||||
|
|
||||||
As of `v1.1.0`: **129 / 129 defended classes demonstrated (recall 100%)** and **6 /
|
As of `v1.2.0`: **130 / 130 defended classes demonstrated (recall 100%)** and **6 /
|
||||||
6 documented gaps still hold** (a *closed* gap fails the test, forcing a doc
|
6 documented gaps still hold** (a *closed* gap fails the test, forcing a doc
|
||||||
update). The matrix is the single source of truth for the test suite (**802
|
update). The matrix is the single source of truth for the test suite (**834
|
||||||
passing**), which also asserts total recall, that every lexicon pattern has a
|
passing**), which also asserts total recall, that every lexicon pattern has a
|
||||||
case (so the matrix cannot fall behind the lexicon), the full LLM02 secret-egress
|
case (so the matrix cannot fall behind the lexicon), the full LLM02 secret-egress
|
||||||
set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb,
|
set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**A reusable, minimal, dependency-light defensive layer for LLM *ingestion*
|
**A reusable, minimal, dependency-light defensive layer for LLM *ingestion*
|
||||||
pipelines — the write-time siblings of query-time chatbot guardrails.**
|
pipelines — the write-time siblings of query-time chatbot guardrails.**
|
||||||
|
|
||||||
Status: implemented — v1.1.0, exported surface frozen under semver. This document defines what the repo contains
|
Status: implemented — v1.2.0, exported surface frozen under semver. This document defines what the repo contains
|
||||||
and why; the stdlib-only core is built and tested (see `README.md` for usage and
|
and why; the stdlib-only core is built and tested (see `README.md` for usage and
|
||||||
`docs/PLAN.md` for the build order).
|
`docs/PLAN.md` for the build order).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,27 @@ 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: the mapping class has no expressible
|
- **OKF frontmatter is a restricted grammar: the mapping class has exactly one
|
||||||
form.** Gate T2 accepts a line-oriented subset deliberately — full YAML is a larger
|
expressible form.** Gate T2 accepts a line-oriented subset deliberately — full YAML
|
||||||
parse-attack surface than a write-time gate needs. Nested mappings and flow
|
is a larger parse-attack surface than a write-time gate needs. Flow sequences
|
||||||
collections (`[a, b]`, `{k: v}`) are *rejected outright*, which fails secure.
|
(`[a, b]`) and nested mappings are *rejected outright*, which fails secure.
|
||||||
**All four routes to a mapping fail, each on a different rule** — flow (`{k: v}`)
|
**Three of the four routes to a mapping fail, each on a different rule** — block
|
||||||
on the disallowed value-start indicator, block (`k:\n sub: v`) on the
|
(`k:\n sub: v`) on the nested-mapping check, dotted keys (`k.sub: v`) on the key
|
||||||
nested-mapping check, dotted keys (`k.sub: v`) on the key pattern, and the inline
|
pattern, and the inline second colon (`k: sub: v`) on the mapping-construct check.
|
||||||
second colon (`k: sub: v`) on the mapping-construct check — so the mapping *class*
|
**The fourth, the flow form, is admitted only when every key is on an allowlist**
|
||||||
has no expressible form, rather than one form being preferable to another. What
|
(`by`, `at`, `from`, `to`, `id`, `title`, `author`, `usage_count`,
|
||||||
survives is scalars and flat lists of strings. **Two of those routes used to
|
`last_modified` — the keys SPEC.md @ `62432a09` §5.1/§5.2 names inside a mapping)
|
||||||
|
and every leaf is a plain scalar, itself run through the same value predicates as a
|
||||||
|
top-level scalar. Nested collections, quoted leaves, duplicate keys, an empty or
|
||||||
|
unclosed mapping, and `{a:b}` (which PyYAML 6.0.3 reads as the *key* `a:b`, not as
|
||||||
|
a scalar) all raise. The form is expressible, never trusted: the allowlist
|
||||||
|
inspects every key, which is the property that carried the security when the
|
||||||
|
blanket refusal was doing the enforcing. **`resource` is deliberately off the
|
||||||
|
allowlist** although §5.1 names it inside a `sources` entry — it is a pointer
|
||||||
|
rather than a label and the only key T3 exists for, so admitting it would let
|
||||||
|
`executor: { resource: skills/run.md }` carry an executable-code pointer through a
|
||||||
|
key the https allowlist never inspects. What else survives is scalars and flat
|
||||||
|
lists of strings. **Two routes used to
|
||||||
degrade into a string instead of failing, and that defect is closed in `1.1.0`**:
|
degrade into a string instead of failing, and that defect is closed in `1.1.0`**:
|
||||||
a block-sequence item carrying exactly one key (`sources:\n - uri: https://e.com/a`
|
a block-sequence item carrying exactly one key (`sources:\n - uri: https://e.com/a`
|
||||||
yielded the *string* `'uri: https://e.com/a'`) and the inline second colon
|
yielded the *string* `'uri: https://e.com/a'`) and the inline second colon
|
||||||
|
|
@ -57,7 +68,13 @@ items; this is the full list, each with the mechanism.
|
||||||
outright). Both parsed "successfully" into the wrong *type*, and a pointer parked
|
outright). Both parsed "successfully" into the wrong *type*, and a pointer parked
|
||||||
in one rode through in a key the `resource` allowlist never inspects — mode-b
|
in one rode through in a key the `resource` allowlist never inspects — mode-b
|
||||||
`import_bundle` returned WARN and wrote the merged concept verbatim. Both now
|
`import_bundle` returned WARN and wrote the merged concept verbatim. Both now
|
||||||
FAIL_SECURE at T2, before the allowlist is reached. **The boundary is where YAML
|
FAIL_SECURE at T2, before the allowlist is reached. **What closed is the type
|
||||||
|
confusion, not pointer-smuggling as a class:** T3 still inspects `resource` and
|
||||||
|
nothing else, so an honest *string* under another key rides through exactly as
|
||||||
|
before — `attester: attesters/sql_equality.py` is WARN, while the same path
|
||||||
|
under `resource:` FAIL_SECUREs. That is by design (the string is scanned like
|
||||||
|
any other frontmatter value under T1) and it is not what `1.1.0` changed.
|
||||||
|
**The boundary is where YAML
|
||||||
puts it**, ground-truthed against PyYAML 6.0.3: `": "` and a trailing `":"` open a
|
puts it**, ground-truthed against PyYAML 6.0.3: `": "` and a trailing `":"` open a
|
||||||
mapping and are refused; a colon carrying neither a space nor a line end
|
mapping and are refused; a colon carrying neither a space nor a line end
|
||||||
(`domain:security`, `https://e.com:8443/a`) does not and still parses, as does a
|
(`domain:security`, `https://e.com:8443/a`) does not and still parses, as does a
|
||||||
|
|
@ -68,13 +85,37 @@ items; this is the full list, each with the mechanism.
|
||||||
door A/B persist path, so frontmatter that fails secure on import passes
|
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*,
|
`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.
|
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
|
- **An OKF v0.2 concept traverses the external-import path only if its `sources` are
|
||||||
of v0.2's backward-breaking migration targets are nested — `timestamp` → `generated.at`,
|
flat.** The wall used to be total: both of v0.2's backward-breaking migration targets
|
||||||
and body `# Citations` → a `sources` block list of mappings — so a conformant v0.2
|
are mappings — `timestamp` → `generated.at`, and body `# Citations` → a `sources`
|
||||||
concept fails secure at the frontmatter gate. This is the correct direction but it is
|
block list of mappings — and a consumer measured **0 of 53** upstream concepts
|
||||||
a compatibility wall, not a policy: v0.2 support requires a deliberate parse-safety
|
through the gate. The trust and provenance layer now passes in its spec form
|
||||||
decision about widening the grammar, and the dangling-or-substituted `executor`/
|
(`generated`, `verified` bare or listed, `usage_window`), so `generated.at` is no
|
||||||
`attester` pointer question only becomes live once that decision is made.
|
longer a wall. **`sources` still is**: SPEC.md writes each entry as a block mapping
|
||||||
|
under a block sequence (`- id: …\n resource: …`), and that carrier stays refused —
|
||||||
|
it is the shape whose one-key degradation smuggled a pointer before `1.1.0`, and
|
||||||
|
reopening it is a separate parse-safety decision, not a corollary of the flow form.
|
||||||
|
A concept whose `sources` are flat strings, or absent, imports. The
|
||||||
|
dangling-or-substituted `executor`/`attester` pointer question stays out of reach
|
||||||
|
for the same reason: both are mappings whose payload key is `resource`.
|
||||||
|
- **`tags` and `description` block the OKF import corpus universally, before the
|
||||||
|
trust layer is even reached.** The line-flat frontmatter parser has no
|
||||||
|
sequence-value type at all: `tags` is present in 53/53 upstream concept
|
||||||
|
documents — 9/53 as a flow sequence (`[a, b, c]`, rejected on the `[`
|
||||||
|
indicator) and 44/53 as a block sequence (`- a` / `- b`, rejected as
|
||||||
|
`"malformed frontmatter line"`) — 100% rejection regardless of form.
|
||||||
|
`description` is present in 53/53; 29/53 is a folded plain scalar continuing
|
||||||
|
on an indented second line, which the parser has no continuation-line model
|
||||||
|
for and misreads as `"nested mappings are not supported"` (the remaining
|
||||||
|
24/53 are single-line and parse fine). Measured directly on the upstream
|
||||||
|
reference bundles (`_okf-upstream/okf` @ `3fcbb9f`): removing `tags` alone
|
||||||
|
lets 4/53 documents pass; removing both `tags` and `description` together
|
||||||
|
(trust layer untouched) lets the same 4/53 pass, and all four then parse
|
||||||
|
`generated` correctly as a mapping. **Independent of both the mapping-form
|
||||||
|
gap and the `sources` block-form gap above:** closing either moves nothing
|
||||||
|
on this corpus, because `tags`/`description` reject before `sources` is ever
|
||||||
|
read. No sequence-value type or continuation-line model exists in the
|
||||||
|
stdlib-only parser to close this with.
|
||||||
- **A persist gate cannot cover execution risk.** OKF v0.2 introduces concepts whose
|
- **A persist gate cannot cover execution risk.** OKF v0.2 introduces concepts whose
|
||||||
purpose is to *name code to be run* (`runtime`, `executor.resource`,
|
purpose is to *name code to be run* (`runtime`, `executor.resource`,
|
||||||
`attester.resource`). This library answers "is this safe to **store**"; executable
|
`attester.resource`). This library answers "is this safe to **store**"; executable
|
||||||
|
|
@ -298,6 +339,21 @@ items; this is the full list, each with the mechanism.
|
||||||
`test_split_tightens_the_trusted_tier_when_both_carriers_are_present` constructs
|
`test_split_tightens_the_trusted_tier_when_both_carriers_are_present` constructs
|
||||||
and pins. Read the zero as "not observed in any of the three populations, each
|
and pins. Read the zero as "not observed in any of the three populations, each
|
||||||
counted against its own denominator", never as "cannot happen".
|
counted against its own denominator", never as "cannot happen".
|
||||||
|
- **The split also LOOSENS the upload door for a lone anchor — the direction it was
|
||||||
|
built for, and the one with a residual worth naming.** Measured as shipped:
|
||||||
|
`<a href="https://ext.example/p">t</a>` on its own emits one
|
||||||
|
`active:raw-html-link` at MEDIUM and disposes `quarantine_review` under
|
||||||
|
`PRESET_USER_UPLOAD` (`warn` under `PRESET_TRUSTED_SOURCE`); through 0.6.1 the
|
||||||
|
same document graded HIGH and `fail_secure`d. An `<img src>` to the same host is
|
||||||
|
untouched — `active:raw-html`, HIGH, `fail_secure`. So an anchor pointing at an
|
||||||
|
attacker-controlled host, arriving on an untrusted upload, is now a human decision
|
||||||
|
rather than a halt. The trade is deliberate and it removes an asymmetry that came
|
||||||
|
from syntax rather than affordance: following an anchor needs a click, exactly like
|
||||||
|
the markdown inline link that has graded MEDIUM since 0.3.1, so the same URL no
|
||||||
|
longer grades two different ways depending on which syntax carries it. It is
|
||||||
|
recorded here so 0.7.0's "frees N documents" is not read as free — what was freed
|
||||||
|
is the click-required class, and MEDIUM is a real grade drop on the door where
|
||||||
|
every finding is trust-escalated.
|
||||||
- **Raw-HTML findings no longer count end tags, and that moved a published field.**
|
- **Raw-HTML findings no longer count end tags, and that moved a published field.**
|
||||||
Through 0.6.1 `</a>` was active by name on its own, so `count` ran roughly 1.6×
|
Through 0.6.1 `</a>` was active by name on its own, so `count` ran roughly 1.6×
|
||||||
the opening-tag total (measured on one corpus) and a start/end pair counted 2.
|
the opening-tag total (measured on one corpus) and a start/end pair counted 2.
|
||||||
|
|
@ -306,6 +362,44 @@ items; this is the full list, each with the mechanism.
|
||||||
every document carrying `</a>`, on a field whose meaning did not change. The
|
every document carrying `</a>`, on a field whose meaning did not change. The
|
||||||
finding count is unaffected: the class still collapses to one finding per class
|
finding count is unaffected: the class still collapses to one finding per class
|
||||||
per document, and `count` was never a document count.
|
per document, and `count` was never a document count.
|
||||||
|
- **Which tags the no-URL narrowing may render inert is a judgement about affordance,
|
||||||
|
and no test in this repo can derive it.** `_URL_AFFORDANCE_TAGS` holds the nine
|
||||||
|
names whose entire active affordance *is* the URL they name — `a`, `area`, `img`,
|
||||||
|
`video`, `audio`, `source`, `track`, `frame`, `frameset` — so carrying no URL
|
||||||
|
attribute they name no target and grade inert. Every other name in the active set
|
||||||
|
stays active with no attributes at all, because it does something a URL cannot
|
||||||
|
describe: `<script>` executes its body, `<style>` restyles, `<form>` submits. That
|
||||||
|
boundary is asserted, not measured. A name placed in the set whose affordance does
|
||||||
|
*not* reduce to its URL would go silently invisible, and no corpus can catch it,
|
||||||
|
because what it produces is an absence — the census counts findings, and a tag that
|
||||||
|
stopped firing contributes nothing to count. The fail-secure choice one branch
|
||||||
|
further in holds the other way and is worth reading beside it: a URL attribute whose
|
||||||
|
*value* this module cannot resolve keeps the tag active
|
||||||
|
(`active_content.py:320-324`), a branch the three corpora exercise **0** times.
|
||||||
|
That zero is empirical, so the predicate is written not to depend on it.
|
||||||
|
- **"Clean" means *graded, no finding raised* — never *cleaned bytes* — and at one
|
||||||
|
measured consumer's door, `warn` is the floor a document must clear to be
|
||||||
|
persisted rather than rejected.** The word is the library's own: a WARN
|
||||||
|
disposition with nothing to report carries the reason string `"clean: no
|
||||||
|
findings"` (`disposition.py:264`), and this project has repeated that word in the
|
||||||
|
tables it sends consumers. `screen_output` is a judgement API — its
|
||||||
|
`DispositionResult` carries `assessment` / `disposition` / `max_severity` /
|
||||||
|
`reasons`, with no sanitized-text field to read off it. Defanging lives in a
|
||||||
|
separate, deliberate call — `neutralize` — that a caller must invoke itself;
|
||||||
|
nothing upstream of that call transforms a byte. Measured against
|
||||||
|
`llm-ingestion-okf`'s `0.7.0` pin (2026-08-13): `inbox.py:139` sets its persist
|
||||||
|
floor to `warn`, and `inbox.py:323` persists anything carrying that disposition
|
||||||
|
into the bundle; its adapter (`guard_adapter.py:70`) forwards the original
|
||||||
|
extracted text, because nothing upstream ever handed it a transformed one. Four
|
||||||
|
raw-HTML carrier forms the 0.7.0 no-URL narrowing grades inert — an
|
||||||
|
`<a aria-label>` with no `href`, a bare `</a>`, `<Frame>`, `<video />` — verified
|
||||||
|
here (`screen_output(..., PRESET_USER_UPLOAD)`) to dispose `warn, clean: no
|
||||||
|
findings`; at that consumer's door the same four land written into the bundle,
|
||||||
|
carrier present verbatim. Neither library is wrong: `screen_output` never
|
||||||
|
promised transformed bytes, and the consumer never called `neutralize` for them.
|
||||||
|
The gap is in reading "clean" as "sanitized" rather than "no finding raised" — a
|
||||||
|
reading this project's own reports invite, and one that will mislead any caller
|
||||||
|
that persists on `warn` without calling `neutralize` itself.
|
||||||
- **Measured, document by document: a large minority of *benign* documents do not
|
- **Measured, document by document: a large minority of *benign* documents do not
|
||||||
persist unattended at the upload door.** The bullets above bound single rules on
|
persist unattended at the upload door.** The bullets above bound single rules on
|
||||||
single URLs. This one bounds the thing a consumer actually feels — how often an
|
single URLs. This one bounds the thing a consumer actually feels — how often an
|
||||||
|
|
@ -432,6 +526,23 @@ items; this is the full list, each with the mechanism.
|
||||||
HIGH under a low-trust preset is `fail_secure`. Report-only means the text is
|
HIGH under a low-trust preset is `fail_secure`. Report-only means the text is
|
||||||
never mutated — it does not mean the finding cannot block.
|
never mutated — it does not mean the finding cannot block.
|
||||||
|
|
||||||
|
- **The two labels a `<script>` tag raises come from patterns that do not match the
|
||||||
|
same strings.** `hybrid-xss:script-tag` is `<script\b[^><]*>`; `active:raw-html`
|
||||||
|
reads the same tag through `HTML_TAG_RE`, which consumes quoted attribute runs
|
||||||
|
atomically and so tolerates both `<` and `>` inside a quoted value. The lexicon's
|
||||||
|
`<` exclusion is not a modelling choice — it is the 0.3.3 ReDoS fix, and widening
|
||||||
|
it back to `[^>]` restores a quadratic arm (the row below carries the numbers).
|
||||||
|
Measured through both scanners: `<script src="a<b">` raises `active:raw-html` and
|
||||||
|
**no** XSS label, while `<script data-t="a>b">` raises both, the lexicon's match
|
||||||
|
simply ending at the quoted `>`. The disposition never moves — the raw-HTML branch
|
||||||
|
grades `<script>` HIGH with no attributes at all, so every shape here still
|
||||||
|
`fail_secure`s under `PRESET_USER_UPLOAD` — so what the divergence costs is the
|
||||||
|
*label*: a consumer filtering findings on the XSS id sees a subset of the script
|
||||||
|
tags the gate actually caught, and must not read that id as the gate's script-tag
|
||||||
|
census. The residual is practically dead in prose (a `<` inside a script tag's
|
||||||
|
quoted attribute region is not an ordinary shape) and is recorded because the
|
||||||
|
asymmetry is invisible from either scanner alone.
|
||||||
|
|
||||||
- **A connection-string password longer than 256 chars is not matched.** The
|
- **A connection-string password longer than 256 chars is not matched.** The
|
||||||
password run in the `*-connstr` egress patterns is bounded by
|
password run in the `*-connstr` egress patterns is bounded by
|
||||||
`MAX_CONNSTR_VALUE`; unbounded, it sits in front of a mandatory `@` and makes
|
`MAX_CONNSTR_VALUE`; unbounded, it sits in front of a mandatory `@` and makes
|
||||||
|
|
@ -448,24 +559,96 @@ items; this is the full list, each with the mechanism.
|
||||||
missed; on one preset it is held for review instead of halted.
|
missed; on one preset it is held for review instead of halted.
|
||||||
|
|
||||||
- **The ReDoS sweep has a measured sensitivity floor, not a clean bill of
|
- **The ReDoS sweep has a measured sensitivity floor, not a clean bill of
|
||||||
health.** All 150 compiled patterns across all eleven regex-bearing modules are
|
health.** All 152 compiled patterns across all eleven regex-bearing modules are
|
||||||
swept arm by arm — payloads synthesised per run from each pattern's own
|
swept arm by arm — payloads synthesised per run from each pattern's own
|
||||||
skeleton, so `[`, `[system]` and `[system](` are each probed separately rather
|
skeleton, so `[`, `[system]` and `[system](` are each probed separately rather
|
||||||
than relying on generic units, and each pattern is timed in the call mode the
|
than relying on generic units, and each pattern is timed in the call mode the
|
||||||
production code uses (`.sub()`/`.finditer()` visit every start position where
|
production code uses (`.sub()`/`.finditer()` visit every start position where
|
||||||
`.match()` cannot). Five patterns were quadratic across 0.3.3 and 0.3.4; all
|
`.match()` cannot). Five patterns were quadratic across 0.3.3 and 0.3.4; all
|
||||||
are fixed. But the sweep flags on *timing*, and it ignores measurements below a
|
are fixed. But the sweep flags on *timing*, and it ignores measurements below a
|
||||||
1.5 ms noise floor at N=8000. A quadratic arm sitting just under that floor
|
1.5 ms noise floor at N=8000 — process CPU time, re-derived on that instrument
|
||||||
would still cost **up to ~23 s** at the 1 000 000-char cap. So the claim this
|
(see the clock bullet below) rather than inherited from the wall clock the
|
||||||
sweep supports is "no arm worse than ~23 s at the cap", not "no quadratic arm
|
script used through 1.1.0. A quadratic arm sitting just under that floor would
|
||||||
remains". The method's blind spot is real and has now been demonstrated twice:
|
still cost **up to ~23 s** at the 1 000 000-char cap — that figure is arithmetic
|
||||||
a generic-payload pass found only one of 0.3.3's two patterns, and 0.3.2's
|
and not a measurement: a quadratic arm costs the square of the length ratio, and
|
||||||
|
1.5 ms × 125 × 125 is 23.4 s. So the claim this sweep supports is "no arm worse
|
||||||
|
than ~23 s at the cap", not "no quadratic arm remains". The method's blind spot
|
||||||
|
is real and has now been demonstrated twice: a generic-payload pass found only
|
||||||
|
one of 0.3.3's two patterns, and 0.3.2's
|
||||||
hand-written rows missed all three of 0.3.4's — including one on `sanitize`,
|
hand-written rows missed all three of 0.3.4's — including one on `sanitize`,
|
||||||
the first thing every ingested document touches. **Two arm shapes the unit-
|
the first thing every ingested document touches. **Two arm shapes the unit-
|
||||||
repetition payloads cannot express** are pinned by hand as a result: a tag that
|
repetition payloads cannot express** are pinned by hand as a result: a tag that
|
||||||
*closes* around a long body, and a run of plain characters carrying no anchor
|
*closes* around a long body, and a run of plain characters carrying no anchor
|
||||||
at all.
|
at all.
|
||||||
|
|
||||||
|
- **A green ReDoS row is evidence only if it has been seen red, and three rows in
|
||||||
|
this suite had never been.** The class is not a bad bound but a payload that cannot
|
||||||
|
reach the defect, and it leaves the row passing under the vulnerable form too. The
|
||||||
|
sub-agent row is the clearest case: the seed's unbounded lazy run costs per *prefix
|
||||||
|
match*, not per character — each start position where `spawn an agent that ` matches
|
||||||
|
drives its own O(N) scan to end-of-string looking for a capability keyword the
|
||||||
|
payload never supplies, so K prefix matches cost K×O(N), and the bounded
|
||||||
|
`(?:\S+\s+){0,12}?` port caps each scan at 12 tokens for K×O(1). A payload that
|
||||||
|
matches the prefix **once** and then pads pays a single lazy run and is linear
|
||||||
|
however long the pad is — two earlier shapes did exactly that, and the row sat
|
||||||
|
measured-dead at 1.2× until the payload was rebuilt as
|
||||||
|
`"spawn an agent that " * 3000`. (The nesting an older comment blamed is a red
|
||||||
|
herring: the inner `.*?` sits in an optional group, never a repeated one.) Measured
|
||||||
|
through `scan_lexicon` with the seed form patched back in — exponent **1.92** against
|
||||||
|
the shipped **1.01**, and **4.091s vs 0.190s** at 12 000 words, the seed breaking the
|
||||||
|
2.0s bound outright. Two siblings were dead for different reasons.
|
||||||
|
`lexicon-script-tag` had to be given its own N=200 000: at the shared N=100 000 the
|
||||||
|
vulnerable `[^>]` form measured only ~1.2–1.4s — under the 2.0s assert, so the row
|
||||||
|
was green under both forms and proved nothing. And
|
||||||
|
`test_gate_is_bounded_on_the_long_attribute_arm` was killed by **this repo's own
|
||||||
|
narrowing**: 0.7.0 put `<a>` in `_URL_AFFORDANCE_TAGS`, so its `<a ` + 100k + `>`
|
||||||
|
payload became inert and returned *before* the body ever reached the arm the row
|
||||||
|
exists to guard — separation 1.0×, 0.028s and no findings, against 12.475s for the
|
||||||
|
same payload carried by `<script `. The carrier was moved to `<script `, which is
|
||||||
|
active by name with no attributes, so no future URL-shaped narrowing can hollow it
|
||||||
|
out the same way. The general rule the three share: a payload must **deny** the
|
||||||
|
literal the vulnerable run sits in front of — a unit that supplies it matches
|
||||||
|
immediately and never exercises the run. **Nothing but hand measurement finds this
|
||||||
|
class.** The row is green either way, so
|
||||||
|
the suite cannot report its own blind spot, and every bound in it should be read as
|
||||||
|
"verified red under the vulnerable form" only where a comment says it was.
|
||||||
|
|
||||||
|
- **Every ReDoS bound in the suite is measured on process CPU time, and so is the
|
||||||
|
sweep that sets the published sensitivity floor.** `tests/redos_clock.py` is the one
|
||||||
|
clock all six test files import — `time.process_time()` — because a blowup is spent
|
||||||
|
cycles while a loaded machine steals wall clock without adding any. On
|
||||||
|
`time.monotonic()` two 0.7.0 rows failed at **2.24s / 3.66s** against a 2.0s bound
|
||||||
|
while two census processes held the CPU, and passed 3/3 on an idle machine: they had
|
||||||
|
been descheduled, not slowed. It lives in one module rather than five copies because
|
||||||
|
`test_output.py::test_the_redos_clock_ignores_time_this_process_did_not_spend` pins
|
||||||
|
one implementation, and four unpinned copies would be free to drift back to a wall
|
||||||
|
clock with nothing going red. **What the CPU clock gives up, stated: a scan that
|
||||||
|
BLOCKS forever burns no CPU, so it would hang the suite instead of failing it.**
|
||||||
|
That is acceptable only because every scanner it measures is pure regex over an
|
||||||
|
in-memory string, with no I/O and no locks — the last wall-clock holdout was retired
|
||||||
|
by auditing its path for anything that could block, not by assumption, and a wall
|
||||||
|
clock guarding a mode that cannot occur still charges the false-red premium
|
||||||
|
(measured there at 21.6s against a 10.0s bound, on a scan that spent 7.6s). **The sweep
|
||||||
|
now runs on the same clock, and closing that divergence bought no sensitivity.**
|
||||||
|
`docs/redos-sweep.py` imports `scan_seconds` instead of timing on
|
||||||
|
`time.monotonic()`, so the 1.5 ms floor at N=8000 and the "~23 s at the cap"
|
||||||
|
figure above are finally in the same currency as the bounds they justify. What
|
||||||
|
the move did *not* do is quiet the sweep, and the floor came back unchanged.
|
||||||
|
Measured over **twelve full runs of all 2585 arms**: the median ratio sits at
|
||||||
|
**1.95–2.03** in every size bucket above 50 µs — the whole surface measures
|
||||||
|
linear — while two-point excursions past the 2.6 flag threshold survive at every
|
||||||
|
magnitude, p99 ratio **2.9–3.3 even above 1 ms**. Flagged arms per run by floor:
|
||||||
|
**6.9 at 0.5 ms, 1.1 at 1.0 ms, 0.33 at 1.5 ms** (0–2 per run), so 1.5 ms is
|
||||||
|
still the knee. Descheduling was never what made this sweep noisy — a ratio
|
||||||
|
computed from two points is. Four distinct arms flagged at the shipped floor
|
||||||
|
across those twelve runs, **each in exactly one of them**, and nine of the twelve
|
||||||
|
runs were clean; eight consecutive runs of the shipped script immediately after a
|
||||||
|
full test run flagged 0–3 arms each, so machine load still moves the count even
|
||||||
|
on a CPU clock. Six flagged arms re-measured over six doublings give exponent
|
||||||
|
**0.97–1.09** and at most 1.2 s at the 1 000 000-char cap. **A single clean run
|
||||||
|
of this sweep is therefore not evidence either** — and neither is a single
|
||||||
|
flagged one.
|
||||||
|
|
||||||
- **Every surface now bounds its input, but not all of them the same way.**
|
- **Every surface now bounds its input, but not all of them the same way.**
|
||||||
`sanitize`, `fence` and `neutralize` raise `OversizeInputError` above
|
`sanitize`, `fence` and `neutralize` raise `OversizeInputError` above
|
||||||
`MAX_INPUT_CHARS` (1 000 000) rather than returning a partially transformed
|
`MAX_INPUT_CHARS` (1 000 000) rather than returning a partially transformed
|
||||||
|
|
@ -528,6 +711,23 @@ fails the test, forcing this doc to be updated:
|
||||||
names the outward fetch whatever severity it carries — and must not infer
|
names the outward fetch whatever severity it carries — and must not infer
|
||||||
"nothing was seen" from a low `Severity`.
|
"nothing was seen" from a low `Severity`.
|
||||||
|
|
||||||
|
- **Under the default action map the assessment axis carries exactly one judgement
|
||||||
|
the disposition does not.** `DEFAULT_ACTION_MAP` sends `NONE` and `LOW` to `WARN`,
|
||||||
|
`ELEVATED` to `QUARANTINE_REVIEW` and `SEVERE` to `FAIL_SECURE` — the last two 1:1.
|
||||||
|
So for any document that carries a finding at all, `assessment` is a relabelling of
|
||||||
|
`disposition` and nothing more; the only thing it adds is *clean* versus *findings
|
||||||
|
present, none dispositive in this context*, which 0.4.0 rendered identically. That
|
||||||
|
collapse is the point (the map is what keeps the separation additive, so a caller
|
||||||
|
ignoring the new axis sees no change), and it is also the limitation: reading
|
||||||
|
`assessment` buys a consumer nothing until it supplies its own `action_map` or needs
|
||||||
|
the clean/low distinction. **The second consequence is on this document.** The
|
||||||
|
published false-positive rates are counts of documents *disposed non-WARN*, and they
|
||||||
|
are a statement about assessed risk only while `NONE` + `LOW` are exactly the WARN
|
||||||
|
pre-image. `tests/test_corpus.py::test_the_published_fp_metric_is_a_risk_statement`
|
||||||
|
pins that equivalence — but it pins it for `DEFAULT_ACTION_MAP`. A caller running its
|
||||||
|
own map makes "disposed non-WARN" a different claim from the one measured here, with
|
||||||
|
nothing in either repo failing to say so.
|
||||||
|
|
||||||
- **A ZWJ hidden between two emoji is exempt, and ZWNJ's own false-positive
|
- **A ZWJ hidden between two emoji is exempt, and ZWNJ's own false-positive
|
||||||
class is untouched.** U+200D composes emoji (👩💻 is WOMAN + ZWJ + PERSONAL
|
class is untouched.** U+200D composes emoji (👩💻 is WOMAN + ZWJ + PERSONAL
|
||||||
COMPUTER), so testing it on codepoint membership alone flagged *and stripped*
|
COMPUTER), so testing it on codepoint membership alone flagged *and stripped*
|
||||||
|
|
@ -546,6 +746,22 @@ fails the test, forcing this doc to be updated:
|
||||||
not pictographic) and no corpus is available here to verify it against, so it
|
not pictographic) and no corpus is available here to verify it against, so it
|
||||||
is parked as a known false-positive class rather than guessed at.
|
is parked as a known false-positive class rather than guessed at.
|
||||||
|
|
||||||
|
- **That context test is one predicate on two surfaces, and the symbol carrying it is
|
||||||
|
private.** `sanitize` owns `_is_joiner_in_emoji_sequence`; `output` imports it
|
||||||
|
(`output.py:74`) instead of restating it, because the same defect had to be fixed on
|
||||||
|
both surfaces and a split would let the input side stop flagging while the output
|
||||||
|
side kept hard-blocking — or the reverse, which is how a carrier reaches a persisted
|
||||||
|
artifact after passing the input gate. The agreement is pinned by
|
||||||
|
`tests/test_output.py::test_output_zwj_narrowing_matches_the_sanitize_side`, which
|
||||||
|
asserts `stripped == flagged` across six shapes — half-context on either side, a
|
||||||
|
leading and a trailing joiner, one genuine in-sequence joiner, and a word split.
|
||||||
|
Two things that pin does not give. The six shapes are hand-written rather than
|
||||||
|
drawn from a corpus, so everywhere outside them
|
||||||
|
the surfaces agree by *shared implementation*, not by test — which is the stronger
|
||||||
|
guarantee only for as long as the import survives. And the leading underscore means
|
||||||
|
the predicate is **not** part of the surface frozen under semver: a consumer that
|
||||||
|
imports it is pinning a private name 1.x makes no promise about.
|
||||||
|
|
||||||
## Out-of-scope (documented boundary)
|
## Out-of-scope (documented boundary)
|
||||||
|
|
||||||
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
|
Embedding/vector-layer defenses (OWASP LLM08, downstream of persist); multimodal
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,30 @@ import importlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SRC = Path(__file__).resolve().parent.parent / "src" / "llm_ingestion_guard"
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC = ROOT / "src" / "llm_ingestion_guard"
|
||||||
sys.path.insert(0, str(SRC.parent))
|
sys.path.insert(0, str(SRC.parent))
|
||||||
|
sys.path.insert(0, str(ROOT / "tests"))
|
||||||
|
|
||||||
from llm_ingestion_guard.lexicon import load_lexicon # noqa: E402
|
from llm_ingestion_guard.lexicon import load_lexicon # noqa: E402
|
||||||
|
from redos_clock import scan_seconds # noqa: E402
|
||||||
|
|
||||||
N1, N2 = 4_000, 8_000
|
N1, N2 = 4_000, 8_000
|
||||||
RATIO_FLAG = 2.6
|
RATIO_FLAG = 2.6
|
||||||
|
# RE-DERIVED on the CPU clock, not inherited from the wall clock this script used
|
||||||
|
# through 1.1.0. The clock move fixed false REDS in the suite's bounds; it bought
|
||||||
|
# this sweep no sensitivity. Over twelve full runs (2585 arms each) the median
|
||||||
|
# ratio is 1.95-2.03 in every size bucket above 50 us -- the whole surface
|
||||||
|
# measures linear -- yet two-point excursions past RATIO_FLAG survive at every
|
||||||
|
# magnitude (p99 ratio 2.9-3.3 even above 1 ms). Flagged arms per run by floor:
|
||||||
|
# 6.9 at 0.5 ms, 1.1 at 1.0 ms, 0.33 at 1.5 ms. The knee is here. Four arms
|
||||||
|
# flagged across those twelve runs, each in exactly ONE of them, and six arms
|
||||||
|
# that have ever flagged re-measure at exponent 0.97-1.09 over six doublings.
|
||||||
|
# Descheduling was never what made this sweep noisy -- a two-point ratio is, so
|
||||||
|
# read a clean run and a flagged run with the same suspicion.
|
||||||
NOISE_FLOOR = 0.0015
|
NOISE_FLOOR = 0.0015
|
||||||
HARD_CAP = 20.0
|
HARD_CAP = 20.0
|
||||||
|
|
||||||
|
|
@ -134,21 +147,35 @@ def build(unit: str, n: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def t(rx: re.Pattern[str], text: str, mode: str = "search") -> float:
|
def t(rx: re.Pattern[str], text: str, mode: str = "search") -> float:
|
||||||
"""Time one scan of ``text`` in the mode the production code actually uses."""
|
"""Time one scan of ``text`` in the mode the production code actually uses.
|
||||||
|
|
||||||
|
On the SAME clock every ReDoS bound in the suite is measured against --
|
||||||
|
``tests/redos_clock.py``, process CPU time -- imported rather than restated
|
||||||
|
here, for the reason that module gives: a blowup is spent cycles, and a
|
||||||
|
loaded machine steals wall clock without adding any. Until 1.1.0 this timed
|
||||||
|
on ``time.monotonic()``, which made the floor below and the cap figure
|
||||||
|
derived from it numbers from a different instrument than the bounds they
|
||||||
|
justify. The closure is built BEFORE the clock starts, so only the scan is
|
||||||
|
charged.
|
||||||
|
"""
|
||||||
mode = mode.rstrip("*")
|
mode = mode.rstrip("*")
|
||||||
start = time.monotonic()
|
|
||||||
if mode == "finditer":
|
if mode == "finditer":
|
||||||
for _ in rx.finditer(text):
|
def scan(s: str) -> None:
|
||||||
pass
|
for _ in rx.finditer(s):
|
||||||
|
pass
|
||||||
elif mode == "sub":
|
elif mode == "sub":
|
||||||
rx.sub("", text)
|
def scan(s: str) -> None:
|
||||||
|
rx.sub("", s)
|
||||||
elif mode == "match":
|
elif mode == "match":
|
||||||
rx.match(text)
|
def scan(s: str) -> None:
|
||||||
|
rx.match(s)
|
||||||
elif mode == "fullmatch":
|
elif mode == "fullmatch":
|
||||||
rx.fullmatch(text)
|
def scan(s: str) -> None:
|
||||||
|
rx.fullmatch(s)
|
||||||
else:
|
else:
|
||||||
rx.search(text)
|
def scan(s: str) -> None:
|
||||||
return time.monotonic() - start
|
rx.search(s)
|
||||||
|
return scan_seconds(scan, text)
|
||||||
|
|
||||||
|
|
||||||
# --- targets ----------------------------------------------------------------
|
# --- targets ----------------------------------------------------------------
|
||||||
|
|
|
||||||
7
llms.txt
Normal file
7
llms.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# llm-ingestion-guard
|
||||||
|
|
||||||
|
> Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v1.1.0"
|
||||||
|
```
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "llm-ingestion-guard"
|
name = "llm-ingestion-guard"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
description = "Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure."
|
description = "Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ from .grounding import (
|
||||||
)
|
)
|
||||||
from . import okf
|
from . import okf
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.2.0"
|
||||||
|
|
||||||
|
|
||||||
# --- §6 bookends: the two library-side halves around the transform ---------
|
# --- §6 bookends: the two library-side halves around the transform ---------
|
||||||
|
|
|
||||||
|
|
@ -542,8 +542,11 @@ def _build_cases() -> list[Case]:
|
||||||
lambda: okf.parse_frontmatter("---\nkey:\n nested: x\n---\nbody\n"), owasp="LLM10"),
|
lambda: okf.parse_frontmatter("---\nkey:\n nested: x\n---\nbody\n"), owasp="LLM10"),
|
||||||
_raise_case("okf", "T2 frontmatter block scalar", "OKFFrontmatterError",
|
_raise_case("okf", "T2 frontmatter block scalar", "OKFFrontmatterError",
|
||||||
lambda: okf.parse_frontmatter("---\ndesc: |\n block\n---\nbody\n"), owasp="LLM10"),
|
lambda: okf.parse_frontmatter("---\ndesc: |\n block\n---\nbody\n"), owasp="LLM10"),
|
||||||
_raise_case("okf", "T2 frontmatter flow collection", "OKFFrontmatterError",
|
_raise_case("okf", "T2 frontmatter flow sequence", "OKFFrontmatterError",
|
||||||
lambda: okf.parse_frontmatter("---\ntags: [a, b]\n---\nbody\n"), owasp="LLM10"),
|
lambda: okf.parse_frontmatter("---\ntags: [a, b]\n---\nbody\n"), owasp="LLM10"),
|
||||||
|
_raise_case("okf", "T2 mapping key off the allowlist", "OKFFrontmatterError",
|
||||||
|
lambda: okf.parse_frontmatter(
|
||||||
|
"---\ngenerated: { by: a, tool: shell }\n---\nbody\n"), owasp="LLM10"),
|
||||||
_raise_case("okf", "T3 resource non-https (http)", "OKFResourceError",
|
_raise_case("okf", "T3 resource non-https (http)", "OKFResourceError",
|
||||||
lambda: okf.validate_resource_url("http://insecure.test/x"), owasp="LLM05"),
|
lambda: okf.validate_resource_url("http://insecure.test/x"), owasp="LLM05"),
|
||||||
_raise_case("okf", "T3 resource data: scheme", "OKFResourceError",
|
_raise_case("okf", "T3 resource data: scheme", "OKFResourceError",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ and feeds scannable text regions into the existing ``sanitize`` / ``scan_output`
|
||||||
|
|
||||||
T2 — frontmatter parse-safety gate. ``parse_frontmatter`` is a *strict,
|
T2 — frontmatter parse-safety gate. ``parse_frontmatter`` is a *strict,
|
||||||
reject-by-default* loader for the minimal OKF frontmatter subset: flat
|
reject-by-default* loader for the minimal OKF frontmatter subset: flat
|
||||||
``key: value`` scalars plus block ``- item`` lists. Every construct the
|
``key: value`` scalars, block ``- item`` lists, and one typed, allowlisted
|
||||||
|
mapping form (``{ by: x, at: y }`` — see :func:`_parse_flow_mapping`). Every construct the
|
||||||
"block anchor/alias DoS + dangerous type coercion" requirement names is refused
|
"block anchor/alias DoS + dangerous type coercion" requirement names is refused
|
||||||
*by construction* — you cannot suffer a billion-laughs alias expansion or a
|
*by construction* — you cannot suffer a billion-laughs alias expansion or a
|
||||||
``!!python/object`` coercion if anchors, aliases and explicit tags are rejected
|
``!!python/object`` coercion if anchors, aliases and explicit tags are rejected
|
||||||
|
|
@ -16,7 +17,9 @@ philosophy, the frontmatter analogue of the ``resource`` reject-gate (T3).
|
||||||
|
|
||||||
Deliberately NOT a general YAML parser. A security tool whose thesis is
|
Deliberately NOT a general YAML parser. A security tool whose thesis is
|
||||||
minimal-dependency should not pull in a full YAML engine whose own features
|
minimal-dependency should not pull in a full YAML engine whose own features
|
||||||
(anchors, tags, merges) are the attack surface being defended against. Quoted
|
(anchors, tags, merges) are the attack surface being defended against. The one
|
||||||
|
mapping form it does admit is admitted key-by-key against an allowlist, not
|
||||||
|
parsed generally: the mapping class is expressible, never trusted. Quoted
|
||||||
scalars are kept verbatim (quotes included) rather than unquoted — the value is
|
scalars are kept verbatim (quotes included) rather than unquoted — the value is
|
||||||
still scanned as text downstream, so an injection inside a quoted value is not
|
still scanned as text downstream, so an injection inside a quoted value is not
|
||||||
lost; richer scalar forms are a future refinement, not a silent parse.
|
lost; richer scalar forms are a future refinement, not a silent parse.
|
||||||
|
|
@ -62,13 +65,32 @@ _KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
||||||
# A plain OKF scalar cannot *begin* with a YAML structural indicator. Any value
|
# A plain OKF scalar cannot *begin* with a YAML structural indicator. Any value
|
||||||
# starting with one signals an anchor (&), alias (*), explicit tag (!), block
|
# starting with one signals an anchor (&), alias (*), explicit tag (!), block
|
||||||
# scalar (|, >), flow collection ([ ] { }), directive (%) or reserved char
|
# scalar (|, >), flow collection ([ ] { }), directive (%) or reserved char
|
||||||
# (@ `) — all outside the supported subset and all rejected.
|
# (@ `) — all outside the supported subset and all rejected. `{` is tried as the
|
||||||
|
# allowlisted mapping form FIRST (G3); it reaches this predicate only as a leaf
|
||||||
|
# inside one, where a nested collection is refused before it can be read.
|
||||||
_DANGEROUS_VALUE_STARTS = frozenset("&*!|>[]{}%@`")
|
_DANGEROUS_VALUE_STARTS = frozenset("&*!|>[]{}%@`")
|
||||||
# A quoted scalar is a scalar in YAML however many colons it carries, so the
|
# A quoted scalar is a scalar in YAML however many colons it carries, so the
|
||||||
# mapping check steps aside for one. The quotes are retained rather than
|
# mapping check steps aside for one. The quotes are retained rather than
|
||||||
# stripped — a pre-existing divergence, pinned in tests/test_okf.py.
|
# stripped — a pre-existing divergence, pinned in tests/test_okf.py.
|
||||||
_QUOTE_STARTS = frozenset("\"'")
|
_QUOTE_STARTS = frozenset("\"'")
|
||||||
|
|
||||||
|
# G3 — the one mapping form T2 can express (operator decision, 2026-08-21).
|
||||||
|
# Every key inside a mapping must be on this allowlist: the form is safe because
|
||||||
|
# the allowlist inspects each key, not because mappings became trusted. The keys
|
||||||
|
# are the ones OKF v0.2 names inside a mapping - `by`/`at` (SPEC.md @ 62432a09
|
||||||
|
# §5.2 `generated`/`verified`), `from`/`to` (§5.1 `usage_window`) and the
|
||||||
|
# `sources`-entry fields (§5.1). `resource` is the one §5.1 key deliberately
|
||||||
|
# LEFT OFF: it is a pointer rather than a label, it is the only key T3 exists
|
||||||
|
# for, and admitting it inside a mapping would re-open the door-C route closed
|
||||||
|
# in 1.1.0 (`executor: {resource: skills/run.md}` puts an executable-code
|
||||||
|
# pointer in a key the https allowlist never inspects). It costs nothing today,
|
||||||
|
# because the conformant carrier for `sources[].resource` is the block-sequence
|
||||||
|
# of block-mappings, which this form does not admit either way.
|
||||||
|
_MAPPING_KEY_ALLOWLIST = frozenset({
|
||||||
|
"by", "at", "from", "to", "id", "title", "author", "usage_count",
|
||||||
|
"last_modified",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class OKFError(Exception):
|
class OKFError(Exception):
|
||||||
"""Base class for OKF adapter rejections."""
|
"""Base class for OKF adapter rejections."""
|
||||||
|
|
@ -106,7 +128,10 @@ def parse_frontmatter(document):
|
||||||
|
|
||||||
Raises ``OKFFrontmatterError`` on an unterminated fence or any construct
|
Raises ``OKFFrontmatterError`` on an unterminated fence or any construct
|
||||||
outside the minimal flat subset (anchors, aliases, explicit tags, merge
|
outside the minimal flat subset (anchors, aliases, explicit tags, merge
|
||||||
keys, block scalars, flow collections, nested mappings).
|
keys, block scalars, flow sequences, nested mappings). The single exception
|
||||||
|
is the typed, allowlisted flow mapping (:func:`_parse_flow_mapping`), which
|
||||||
|
parses into a ``dict`` of allowlisted keys with plain-scalar leaves — every
|
||||||
|
other route to a mapping still raises.
|
||||||
"""
|
"""
|
||||||
lines = document.split("\n")
|
lines = document.split("\n")
|
||||||
if not lines or lines[0].strip() != _FENCE:
|
if not lines or lines[0].strip() != _FENCE:
|
||||||
|
|
@ -149,13 +174,29 @@ def _scannable_regions(frontmatter, body):
|
||||||
"""The text regions of a concept that carry attacker-controlled content."""
|
"""The text regions of a concept that carry attacker-controlled content."""
|
||||||
regions = [body]
|
regions = [body]
|
||||||
for value in frontmatter.values():
|
for value in frontmatter.values():
|
||||||
if isinstance(value, list):
|
regions.extend(_value_regions(value))
|
||||||
regions.extend(value)
|
|
||||||
elif value:
|
|
||||||
regions.append(value)
|
|
||||||
return regions
|
return regions
|
||||||
|
|
||||||
|
|
||||||
|
def _value_regions(value):
|
||||||
|
"""Every scannable leaf of one frontmatter value.
|
||||||
|
|
||||||
|
A mapping value (G3) is a new *shape* on this surface, not a new exemption:
|
||||||
|
its leaves are scanned exactly like a scalar or a list item, so an injection
|
||||||
|
parked in ``generated: { by: ... }`` reaches ``scan_output`` like any other
|
||||||
|
frontmatter text. Mapping *keys* are not scanned because they cannot carry
|
||||||
|
attacker text - the allowlist admits nine fixed names and nothing else.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return [leaf for leaf in value.values() if leaf]
|
||||||
|
if isinstance(value, list):
|
||||||
|
regions = []
|
||||||
|
for item in value:
|
||||||
|
regions.extend(_value_regions(item))
|
||||||
|
return regions
|
||||||
|
return [value] if value else []
|
||||||
|
|
||||||
|
|
||||||
def validate_concept_path(path, *, allow_reserved=False):
|
def validate_concept_path(path, *, allow_reserved=False):
|
||||||
"""Validate a bundle-relative concept path and return its concept-ID.
|
"""Validate a bundle-relative concept path and return its concept-ID.
|
||||||
|
|
||||||
|
|
@ -579,6 +620,12 @@ def _parse_flat(fm_lines):
|
||||||
result[key] = items if items is not None else ""
|
result[key] = items if items is not None else ""
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
mapping = _parse_flow_mapping(value)
|
||||||
|
if mapping is not None:
|
||||||
|
result[key] = mapping
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
_reject_dangerous_value(value)
|
_reject_dangerous_value(value)
|
||||||
_reject_mapping_construct(value)
|
_reject_mapping_construct(value)
|
||||||
result[key] = value
|
result[key] = value
|
||||||
|
|
@ -605,6 +652,11 @@ def _consume_block_list(fm_lines, start):
|
||||||
continue
|
continue
|
||||||
if raw[:1] in (" ", "\t") and stripped.startswith("- "):
|
if raw[:1] in (" ", "\t") and stripped.startswith("- "):
|
||||||
item = stripped[2:].strip()
|
item = stripped[2:].strip()
|
||||||
|
mapping = _parse_flow_mapping(item)
|
||||||
|
if mapping is not None:
|
||||||
|
items.append(mapping)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
_reject_dangerous_value(item)
|
_reject_dangerous_value(item)
|
||||||
_reject_mapping_construct(item)
|
_reject_mapping_construct(item)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
|
@ -627,8 +679,10 @@ def _reject_dangerous_value(value):
|
||||||
def _reject_mapping_construct(value):
|
def _reject_mapping_construct(value):
|
||||||
"""Reject a scalar that YAML reads as a mapping rather than as a string.
|
"""Reject a scalar that YAML reads as a mapping rather than as a string.
|
||||||
|
|
||||||
T2 gives the mapping *class* no expressible form — flow, nested-block and
|
T2 gives the mapping *class* exactly one expressible form, the typed
|
||||||
dotted-key routes all raise. Two routes used to escape that by degrading
|
allowlisted flow mapping (G3); the nested-block and dotted-key routes still
|
||||||
|
raise, and this predicate is what keeps them raising — both at the top level
|
||||||
|
and on a leaf *inside* an admitted mapping. Two routes used to escape by degrading
|
||||||
into a string instead: a block-sequence item carrying exactly one key
|
into a string instead: a block-sequence item carrying exactly one key
|
||||||
(``- uri: x``), and an inline second colon (``attester: resource: x``).
|
(``- uri: x``), and an inline second colon (``attester: resource: x``).
|
||||||
Both parsed "successfully" into the wrong *type*, and a pointer parked in
|
Both parsed "successfully" into the wrong *type*, and a pointer parked in
|
||||||
|
|
@ -648,3 +702,99 @@ def _reject_mapping_construct(value):
|
||||||
raise OKFFrontmatterError(
|
raise OKFFrontmatterError(
|
||||||
"a mapping is not expressible in OKF frontmatter: %r" % (value,)
|
"a mapping is not expressible in OKF frontmatter: %r" % (value,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_flow_mapping(value):
|
||||||
|
"""Parse ``{ key: value, ... }`` into a typed dict, or refuse it (G3).
|
||||||
|
|
||||||
|
Returns ``None`` when ``value`` does not open a flow mapping, so the caller
|
||||||
|
falls through to the unchanged scalar rules. Otherwise the value either
|
||||||
|
parses into a ``dict`` of allowlisted keys with plain-scalar leaves, or
|
||||||
|
raises - it never degrades into a string, which is the defect closed in
|
||||||
|
1.1.0 and not reopened here.
|
||||||
|
|
||||||
|
Why the mapping class needed *a* form at all: OKF v0.2 writes its whole
|
||||||
|
trust and provenance layer as mappings, and SPEC.md @ ``62432a09`` uses flow
|
||||||
|
form in its own examples (§5.1 ``usage_window``, §5.2 ``generated`` /
|
||||||
|
``verified``). §11 goes further than "should": a consumer *MUST* treat a
|
||||||
|
bare ``verified`` mapping as a one-element list - a rule that presupposes
|
||||||
|
the mapping parses. With no form, 0 of 53 upstream concepts reached the
|
||||||
|
gate, and no threshold would have changed that.
|
||||||
|
|
||||||
|
Why this form is safe: the allowlist inspects **every key**, which is the
|
||||||
|
property that actually carried the security in T2 - the blanket refusal was
|
||||||
|
the enforcement, not the point. Admitted, ground-truthed against PyYAML
|
||||||
|
6.0.3:
|
||||||
|
|
||||||
|
- one flow mapping per value, closed on the same line (``{ a: b }``);
|
||||||
|
- keys on :data:`_MAPPING_KEY_ALLOWLIST` and matching ``_KEY_RE``, no
|
||||||
|
duplicates - PyYAML resolves a duplicate last-wins, which is a way to
|
||||||
|
show one claim and mean another;
|
||||||
|
- plain-scalar leaves only, each run through the *unchanged*
|
||||||
|
``_reject_dangerous_value`` / ``_reject_mapping_construct`` predicates, so
|
||||||
|
a leaf can no more open an anchor, a tag or a nested mapping than a
|
||||||
|
top-level scalar can.
|
||||||
|
|
||||||
|
Refused, each on its own rule: nested collections (``{ a: { b: c } }``,
|
||||||
|
``{ a: [1] }``), quoted leaves, an empty mapping, an unclosed or
|
||||||
|
trailing-junk value (``{ a: b } x``, which PyYAML also refuses), a key
|
||||||
|
outside the allowlist, and ``{a:b}`` - which PyYAML reads as the *key*
|
||||||
|
``a:b``, not as a scalar, and which the required ``": "`` separator catches.
|
||||||
|
|
||||||
|
Two deliberate divergences from PyYAML, both toward refusal: a quoted leaf
|
||||||
|
(``{ title: 'a, b' }``) and a trailing comment (``{ a: b } # note``) are
|
||||||
|
conformant YAML that this rejects. Splitting quoted commas correctly needs a
|
||||||
|
quote state machine whose failure mode is *accepting* something YAML would
|
||||||
|
refuse; refusing is the cheaper side to be wrong on, and the keys that
|
||||||
|
plausibly need a comma (``title``, ``author``) only occur inside ``sources``
|
||||||
|
entries, whose block-sequence carrier is refused anyway.
|
||||||
|
"""
|
||||||
|
if not value or value[0] != "{":
|
||||||
|
return None
|
||||||
|
if not value.endswith("}"):
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"a flow mapping must be closed by '}' on the same line: %r" % (value,)
|
||||||
|
)
|
||||||
|
|
||||||
|
inner = value[1:-1].strip()
|
||||||
|
if inner.endswith(","): # a trailing comma is legal YAML; one, and only one
|
||||||
|
inner = inner[:-1].strip()
|
||||||
|
if not inner:
|
||||||
|
raise OKFFrontmatterError("an empty flow mapping carries nothing: %r" % (value,))
|
||||||
|
for char in "{}[]":
|
||||||
|
if char in inner:
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"a flow mapping admits scalar leaves only, not %r: %r" % (char, value)
|
||||||
|
)
|
||||||
|
for quote in _QUOTE_STARTS:
|
||||||
|
if quote in inner:
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"a quoted scalar inside a flow mapping is not a supported form: %r"
|
||||||
|
% (value,)
|
||||||
|
)
|
||||||
|
|
||||||
|
mapping = {}
|
||||||
|
for entry in inner.split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
key, sep, leaf = entry.partition(": ")
|
||||||
|
if not sep:
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"a flow-mapping entry must be 'key: value': %r" % (entry,)
|
||||||
|
)
|
||||||
|
key = key.strip()
|
||||||
|
leaf = leaf.strip()
|
||||||
|
if not _KEY_RE.match(key):
|
||||||
|
raise OKFFrontmatterError("invalid flow-mapping key: %r" % (key,))
|
||||||
|
if key not in _MAPPING_KEY_ALLOWLIST:
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"flow-mapping key %r is not on the OKF mapping allowlist: %r"
|
||||||
|
% (key, value)
|
||||||
|
)
|
||||||
|
if key in mapping:
|
||||||
|
raise OKFFrontmatterError(
|
||||||
|
"duplicate flow-mapping key %r: %r" % (key, value)
|
||||||
|
)
|
||||||
|
_reject_dangerous_value(leaf)
|
||||||
|
_reject_mapping_construct(leaf)
|
||||||
|
mapping[key] = leaf
|
||||||
|
return mapping
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import redos_clock
|
||||||
from llm_ingestion_guard import Disposition, PRESET_USER_UPLOAD, Risk, screen_output
|
from llm_ingestion_guard import Disposition, PRESET_USER_UPLOAD, Risk, screen_output
|
||||||
from llm_ingestion_guard import active_content as ac
|
from llm_ingestion_guard import active_content as ac
|
||||||
|
|
||||||
_DOCS = Path(__file__).resolve().parent.parent / "docs"
|
_DOCS = Path(__file__).resolve().parent.parent / "docs"
|
||||||
|
_LIMITATIONS = _DOCS / "LIMITATIONS.md"
|
||||||
|
|
||||||
|
|
||||||
def _load(filename: str):
|
def _load(filename: str):
|
||||||
|
|
@ -41,6 +43,7 @@ def _load(filename: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
assert spec and spec.loader, f"cannot load {path}"
|
assert spec and spec.loader, f"cannot load {path}"
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module # @dataclass resolves its own module via sys.modules
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
@ -49,6 +52,7 @@ def _load(filename: str):
|
||||||
# script's import list fails the whole file loudly instead of one quiet test.
|
# script's import list fails the whole file loudly instead of one quiet test.
|
||||||
fp_sweep = _load("fp-sweep.py")
|
fp_sweep = _load("fp-sweep.py")
|
||||||
census = _load("rawhtml-census.py")
|
census = _load("rawhtml-census.py")
|
||||||
|
redos_sweep = _load("redos-sweep.py")
|
||||||
|
|
||||||
|
|
||||||
# --- docs/fp-sweep.py --------------------------------------------------------
|
# --- docs/fp-sweep.py --------------------------------------------------------
|
||||||
|
|
@ -245,6 +249,38 @@ def test_census_production_row_equals_its_shipped_candidate(name, attrs):
|
||||||
assert candidate(name, attrs) == ac.active_tag_class(name, attrs)
|
assert candidate(name, attrs) == ac.active_tag_class(name, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- docs/redos-sweep.py ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_redos_sweep_times_on_the_suite_clock_not_a_reimplementation():
|
||||||
|
# Until 1.1.0 this script timed on `time.monotonic()`, a different instrument
|
||||||
|
# than every ReDoS bound in the suite. `t()` must call the shared
|
||||||
|
# `scan_seconds` — imported, not restated — and the module must not import
|
||||||
|
# `time` itself, else a drift back to a wall clock would go unnoticed here.
|
||||||
|
assert redos_sweep.scan_seconds is redos_clock.scan_seconds
|
||||||
|
assert not hasattr(redos_sweep, "time"), "module must not import time itself"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redos_sweep_floor_and_flag_match_the_published_numbers():
|
||||||
|
# docs/LIMITATIONS.md publishes the 1.5 ms floor and the 2.6 flag ratio this
|
||||||
|
# script derives from twelve full runs. Pin both sides: the constants, and
|
||||||
|
# that the doc still states the same numbers — either drifting alone is a bug.
|
||||||
|
assert redos_sweep.NOISE_FLOOR == 0.0015
|
||||||
|
assert redos_sweep.RATIO_FLAG == 2.6
|
||||||
|
text = _LIMITATIONS.read_text(encoding="utf-8")
|
||||||
|
assert "1.5 ms noise floor" in text
|
||||||
|
assert "2.6 flag threshold" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_redos_sweep_collector_covers_152_patterns_across_11_tables():
|
||||||
|
# The count docs/LIMITATIONS.md carries as "all 152 compiled patterns across
|
||||||
|
# all eleven regex-bearing modules". A pattern added or removed in `src/`
|
||||||
|
# without re-measuring would drift the doc's claim silently otherwise.
|
||||||
|
assert len(redos_sweep.TABLES) == 11
|
||||||
|
total = sum(len(collect()) for collect in redos_sweep.TABLES.values())
|
||||||
|
assert total == 152
|
||||||
|
|
||||||
|
|
||||||
# --- both scripts: the argument-less contract --------------------------------
|
# --- both scripts: the argument-less contract --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -596,9 +596,11 @@ _DEGRADED_TO_STRING = [
|
||||||
@pytest.mark.parametrize("cid,fm,yaml_reads_as", _DEGRADED_TO_STRING,
|
@pytest.mark.parametrize("cid,fm,yaml_reads_as", _DEGRADED_TO_STRING,
|
||||||
ids=[c[0] for c in _DEGRADED_TO_STRING])
|
ids=[c[0] for c in _DEGRADED_TO_STRING])
|
||||||
def test_a_mapping_construct_never_degrades_into_a_string(cid, fm, yaml_reads_as):
|
def test_a_mapping_construct_never_degrades_into_a_string(cid, fm, yaml_reads_as):
|
||||||
# The mapping *class* has no expressible form through T2 — so a mapping
|
# None of these shapes is the one form T2 admits (G3, the allowlisted flow
|
||||||
# construct must RAISE, never parse "successfully" into the wrong type. A
|
# mapping) — so each must RAISE, never parse "successfully" into the wrong
|
||||||
# consumer reading frontmatter["sources"][0].get("uri") must not be handed a str.
|
# type. A consumer reading frontmatter["sources"][0].get("uri") must not be
|
||||||
|
# handed a str, and that holds whether the mapping class has no expressible
|
||||||
|
# form or one.
|
||||||
with pytest.raises(OKFFrontmatterError):
|
with pytest.raises(OKFFrontmatterError):
|
||||||
parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")
|
parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")
|
||||||
|
|
||||||
|
|
@ -651,13 +653,17 @@ def test_pointer_in_a_degraded_mapping_no_longer_reaches_the_consumer_tree(cid,
|
||||||
assert result.disposition is Disposition.FAIL_SECURE, "hole reopened — see LIMITATIONS.md"
|
assert result.disposition is Disposition.FAIL_SECURE, "hole reopened — see LIMITATIONS.md"
|
||||||
|
|
||||||
|
|
||||||
def test_every_route_to_a_mapping_fails_on_a_different_rule():
|
def test_exactly_one_route_to_a_mapping_is_expressible():
|
||||||
# The v0.2 wall is not a choice between two forms where one is better: ALL three
|
# Was: ALL FOUR routes failed, each on its own rule, so the mapping *class* had
|
||||||
# ways to express a mapping fail, each on its own rule, so the mapping *class* has
|
# no expressible form (and v0.2's `generated` could not be written at all). G3
|
||||||
# no expressible form through T2. v0.2's `generated` IS a mapping (`by` required
|
# opens exactly ONE of them - the allowlisted flow form - and the other three
|
||||||
# when present), so it cannot be expressed at all.
|
# still fail, each on its own rule. That the openable route is the one whose
|
||||||
|
# every key the allowlist inspects is the whole design: block, dotted and inline
|
||||||
|
# give the allowlist nothing to inspect, so they stay shut.
|
||||||
|
assert parse_frontmatter("---\nid: x\ngenerated: { by: x, at: y }\n---\n\nbody\n")[0][
|
||||||
|
"generated"] == {"by": "x", "at": "y"}
|
||||||
|
|
||||||
routes = {
|
routes = {
|
||||||
"flow": "generated: { by: x, at: y }\n",
|
|
||||||
"block": "generated:\n by: x\n",
|
"block": "generated:\n by: x\n",
|
||||||
"dotted": "generated.by: x\n",
|
"dotted": "generated.by: x\n",
|
||||||
"inline": "generated: by: x\n",
|
"inline": "generated: by: x\n",
|
||||||
|
|
@ -667,11 +673,10 @@ def test_every_route_to_a_mapping_fails_on_a_different_rule():
|
||||||
with pytest.raises(OKFFrontmatterError) as exc:
|
with pytest.raises(OKFFrontmatterError) as exc:
|
||||||
parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")
|
parse_frontmatter(f"---\nid: x\n{fm}---\n\nbody\n")
|
||||||
errors[name] = str(exc.value)
|
errors[name] = str(exc.value)
|
||||||
assert "indicator" in errors["flow"]
|
|
||||||
assert "nested mappings" in errors["block"]
|
assert "nested mappings" in errors["block"]
|
||||||
assert "key" in errors["dotted"]
|
assert "key" in errors["dotted"]
|
||||||
assert "mapping" in errors["inline"]
|
assert "mapping" in errors["inline"]
|
||||||
assert len(set(errors.values())) == 4, "routes must fail distinctly, not collapse"
|
assert len(set(errors.values())) == 3, "routes must fail distinctly, not collapse"
|
||||||
|
|
||||||
|
|
||||||
_BLOCK_LIST_ITEM_SHAPES = [
|
_BLOCK_LIST_ITEM_SHAPES = [
|
||||||
|
|
@ -698,7 +703,9 @@ def test_two_keys_per_item_is_where_the_block_list_hard_rejects():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("fm", [
|
@pytest.mark.parametrize("fm", [
|
||||||
"generated: { by: x, at: y }\n", "sources: [{ id: a }]\n", "tags: [a, b]\n",
|
# The flow row carries a key OFF the G3 allowlist: the shape is admitted, the
|
||||||
|
# key is not, so this stays a T2 rejection and the door A/B half still holds.
|
||||||
|
"generated: { by: x, tool: y }\n", "sources: [{ id: a }]\n", "tags: [a, b]\n",
|
||||||
"generated:\n by: x\n", "generated.by: x\n",
|
"generated:\n by: x\n", "generated.by: x\n",
|
||||||
"sources:\n - id: a\n resource: file://x\n",
|
"sources:\n - id: a\n resource: file://x\n",
|
||||||
])
|
])
|
||||||
|
|
@ -738,3 +745,161 @@ def test_link_extraction_survives_the_redos_fix():
|
||||||
assert extract_link_targets("see [x](./a.md) and [y](/b.md)") == ["./a.md", "/b.md"]
|
assert extract_link_targets("see [x](./a.md) and [y](/b.md)") == ["./a.md", "/b.md"]
|
||||||
assert extract_link_targets("[a b](./c.md)") == ["./c.md"]
|
assert extract_link_targets("[a b](./c.md)") == ["./c.md"]
|
||||||
assert extract_link_targets("text [](./t.md)") == ["./i.png"]
|
assert extract_link_targets("text [](./t.md)") == ["./i.png"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- G3: the typed, allowlisted mapping form (2026-08-21) --------------------
|
||||||
|
# Door 1 of three (operator decision, 2026-08-21). The mapping *class* had no
|
||||||
|
# expressible form, and OKF v0.2 writes its whole trust and provenance layer as
|
||||||
|
# mappings — SPEC.md @ 62432a09 §5.2 uses flow form in its own examples, and §11
|
||||||
|
# carries a hard MUST that presupposes they parse ("consumers MUST treat a bare
|
||||||
|
# `verified` mapping as a one-element list"). A consumer measured 0 of 53
|
||||||
|
# upstream concepts through the gate. This admits ONE shape: a flow mapping whose
|
||||||
|
# every key is on the allowlist and whose every leaf is a plain scalar.
|
||||||
|
|
||||||
|
def test_spec_flow_mapping_parses_into_a_typed_mapping():
|
||||||
|
# SPEC.md §5.2, verbatim. This is the red test: it must fail before the form
|
||||||
|
# exists and pass after, with a real dict — never a degraded string.
|
||||||
|
doc = (
|
||||||
|
"---\ntype: table\n"
|
||||||
|
"generated: { by: reference_agent/gemini-2.5-pro, at: 2026-06-20T22:53:05Z }\n"
|
||||||
|
"---\nbody\n"
|
||||||
|
)
|
||||||
|
assert parse_frontmatter(doc)[0]["generated"] == {
|
||||||
|
"by": "reference_agent/gemini-2.5-pro",
|
||||||
|
"at": "2026-06-20T22:53:05Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_bare_verified_mapping_parses():
|
||||||
|
# SPEC.md §5.2's bare form, which §11 turns into a hard MUST for consumers
|
||||||
|
# ("MUST treat a bare `verified` mapping as a one-element list") - a rule that
|
||||||
|
# cannot be obeyed by a consumer that cannot parse the mapping.
|
||||||
|
doc = "---\ntype: table\nverified: { by: human:ahormati, at: 2026-06-25T09:00:00Z }\n---\nb\n"
|
||||||
|
assert parse_frontmatter(doc)[0]["verified"] == {
|
||||||
|
"by": "human:ahormati", "at": "2026-06-25T09:00:00Z"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_verified_list_of_flow_mappings_parses():
|
||||||
|
# §5.2's list form. This is the SAME typed form in list position, not the
|
||||||
|
# block-sequence-with-one-key route (`- uri: x`), which stays shut below.
|
||||||
|
doc = (
|
||||||
|
"---\ntype: table\nverified:\n"
|
||||||
|
" - { by: human:ahormati, at: 2026-06-25T09:00:00Z }\n"
|
||||||
|
" - { by: process:finance-nightly, at: 2026-06-26T02:00:00Z }\n"
|
||||||
|
"---\nbody\n"
|
||||||
|
)
|
||||||
|
assert parse_frontmatter(doc)[0]["verified"] == [
|
||||||
|
{"by": "human:ahormati", "at": "2026-06-25T09:00:00Z"},
|
||||||
|
{"by": "process:finance-nightly", "at": "2026-06-26T02:00:00Z"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_usage_window_parses():
|
||||||
|
doc = "---\ntype: table\nusage_window: { from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }\n---\nb\n"
|
||||||
|
assert parse_frontmatter(doc)[0]["usage_window"] == {
|
||||||
|
"from": "2026-06-01T00:00:00Z", "to": "2026-06-30T00:00:00Z"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_key_inside_a_mapping_is_still_rejected():
|
||||||
|
# The rejection side of the allowlist. Without this test the allowlist could
|
||||||
|
# silently grow to "anything" - or be emptied - and nothing would fail.
|
||||||
|
with pytest.raises(OKFFrontmatterError) as exc:
|
||||||
|
parse_frontmatter("---\nid: x\ngenerated: { by: a, tool: shell }\n---\n\nbody\n")
|
||||||
|
assert "allowlist" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_allowlist_is_not_empty_and_admits_only_the_spec_keys():
|
||||||
|
# Both directions of the same guard: a shrunk allowlist breaks the first
|
||||||
|
# assertion, a widened one the second.
|
||||||
|
for key in ("by", "at", "from", "to", "id", "title", "author", "usage_count",
|
||||||
|
"last_modified"):
|
||||||
|
assert parse_frontmatter(f"---\nid: x\nk: {{ {key}: v }}\n---\n\nb\n")[0]["k"] == {key: "v"}
|
||||||
|
for key in ("resource", "executor", "attester", "runtime", "command", "uri"):
|
||||||
|
with pytest.raises(OKFFrontmatterError):
|
||||||
|
parse_frontmatter(f"---\nid: x\nk: {{ {key}: v }}\n---\n\nb\n")
|
||||||
|
|
||||||
|
|
||||||
|
_FLOW_REJECTED = [
|
||||||
|
# (id, value, what PyYAML 6.0.3 makes of it)
|
||||||
|
("nested mapping", "{ by: { at: x } }", "a nested mapping"),
|
||||||
|
("nested sequence", "{ by: [a, b] }", "a sequence leaf"),
|
||||||
|
("anchor leaf", "{ by: &a x }", "an anchor definition, silently"),
|
||||||
|
("tag leaf", "{ by: !!python/object:os.system x }", "refused outright"),
|
||||||
|
("block scalar leaf", "{ by: | }", "a scanner error"),
|
||||||
|
("nested colon leaf", "{ by: sub: v }", "refused outright"),
|
||||||
|
("no space after colon", "{by:x}", "the KEY 'by:x', not a scalar"),
|
||||||
|
("quoted leaf", "{ title: 'a, b' }", "a scalar - we refuse, deliberately"),
|
||||||
|
("empty mapping", "{}", "an empty mapping"),
|
||||||
|
("empty leaf", "{ by: }", "None"),
|
||||||
|
("unclosed", "{ by: x", "a parse error"),
|
||||||
|
("trailing junk", "{ by: x } more", "a parse error"),
|
||||||
|
("duplicate key", "{ by: a, by: b }", "last-wins, silently"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cid,value,yaml_reads_as", _FLOW_REJECTED,
|
||||||
|
ids=[c[0] for c in _FLOW_REJECTED])
|
||||||
|
def test_the_mapping_form_admits_scalar_leaves_on_allowlisted_keys_only(cid, value, yaml_reads_as):
|
||||||
|
with pytest.raises(OKFFrontmatterError):
|
||||||
|
parse_frontmatter(f"---\nid: x\ngenerated: {value}\n---\n\nbody\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejected_mapping_never_degrades_into_a_string():
|
||||||
|
# The 1.1.0 defect, re-asserted against the NEW form: a refused mapping must
|
||||||
|
# raise, not arrive as a str a consumer will .get() a key out of.
|
||||||
|
for value in ("{ by: { at: x } }", "{ tool: shell }", "{ by: x"):
|
||||||
|
with pytest.raises(OKFFrontmatterError):
|
||||||
|
parse_frontmatter(f"---\nid: x\ngenerated: {value}\n---\n\nbody\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admitted_mapping_is_a_dict_not_a_string():
|
||||||
|
value = parse_frontmatter("---\nid: x\ngenerated: { by: a, at: b }\n---\n\nb\n")[0]["generated"]
|
||||||
|
assert isinstance(value, dict), "a typed form that arrives as a str is the 1.1.0 defect"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cid,fm", [
|
||||||
|
("block sequence, one key", "attester:\n - resource: attesters/sql_equality.py\n"),
|
||||||
|
("inline second colon", "attester: resource: attesters/sql_equality.py\n"),
|
||||||
|
("block mapping", "attester:\n resource: attesters/sql_equality.py\n"),
|
||||||
|
("flow mapping, pointer key", "attester: { resource: attesters/sql_equality.py }\n"),
|
||||||
|
])
|
||||||
|
def test_the_pointer_routes_stay_shut(cid, fm):
|
||||||
|
# G3 is additive: none of the routes that put an executable-code pointer in a
|
||||||
|
# key the https allowlist never inspects is reopened. The fourth row is why
|
||||||
|
# `resource` is off the allowlist - the form would otherwise have carried the
|
||||||
|
# door-C pointer through in typed clothes instead of degraded ones.
|
||||||
|
doc = f"---\nid: x\ntype: Attested Computation\n{fm}---\n\nbody\n"
|
||||||
|
with pytest.raises(OKFFrontmatterError):
|
||||||
|
parse_frontmatter(doc)
|
||||||
|
assert import_bundle({"computations/x.md": doc}).disposition is Disposition.FAIL_SECURE
|
||||||
|
|
||||||
|
|
||||||
|
def test_injection_in_a_mapping_leaf_is_caught_by_the_scan():
|
||||||
|
# T1 is not weakened by the new shape: a mapping leaf is scanned exactly like a
|
||||||
|
# scalar value or a list item. A typed form that parses but is not scanned would
|
||||||
|
# be a hole, not a fix.
|
||||||
|
doc = f"---\ntype: table\ngenerated: {{ by: {_INJECTION} }}\n---\nclean body\n"
|
||||||
|
assert scan_concept(doc).found is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_injection_in_a_listed_mapping_leaf_is_caught_by_the_scan():
|
||||||
|
doc = f"---\ntype: table\nverified:\n - {{ by: {_INJECTION} }}\n---\nclean body\n"
|
||||||
|
assert scan_concept(doc).found is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_conformant_v02_trust_layer_now_reaches_the_gate():
|
||||||
|
# The measured consequence: a consumer reported 0 of 53 upstream concepts through
|
||||||
|
# the gate, because every one of them carries §5.2 trust frontmatter.
|
||||||
|
doc = (
|
||||||
|
"---\n"
|
||||||
|
"type: table\n"
|
||||||
|
"title: Users\n"
|
||||||
|
"resource: https://example.com/users\n"
|
||||||
|
"generated: { by: reference_agent/gemini-2.5-pro, at: 2026-06-20T22:53:05Z }\n"
|
||||||
|
"verified: { by: human:ahormati, at: 2026-06-25T09:00:00Z }\n"
|
||||||
|
"usage_window: { from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }\n"
|
||||||
|
"---\nThe users table.\n"
|
||||||
|
)
|
||||||
|
result = import_bundle({"tables/users.md": doc})
|
||||||
|
assert result.disposition is Disposition.WARN
|
||||||
|
assert result.concepts[0].error is None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue