1
0
Fork 0

feat(okf): sources becomes expressible, and the parent key is what admits resource

Order 20260902T150716Z from .claude -- a K5 blocker in the OKF programme.
`parse_frontmatter` rejected `sources` in every form the spec and its
producers actually use. Measured 02.09 by two consumers independently:
`sources: [{ id: a, resource: x }]` raised on the `[` indicator (one entry
as well as two), and the block sequence of block mappings -- SPEC.md 5.1's
OWN example -- raised "nested mappings are not supported". `resource` is
REQUIRED within a `sources` entry, so the whole provenance family was
unwritable and a bundle written the way the spec documents it was refused.

Measured against the spec before coding, not reasoned: 5.1's example block
is the canonical carrier for a REQUIRED field and 11.1 defines conformance
as parseable frontmatter, so refusing it refuses a conformant bundle. Both
carriers now parse to the same list of dicts.

The load-bearing change is not the carrier, it is WHO admits `resource`.
1.2.0 left it off the allowlist arguing the parser could not tell
`sources[].resource` (5.1, a citation) from `executor.resource` /
`attester.resource` (10, a pointer to code to be run -- the door-C route
closed in 1.1.0). That premise was false: the owning key is in scope at
every call site and was simply never threaded through. It is threaded now,
so the discrimination is structural, and door C stays shut through EVERY
carrier including the two this adds -- pinned by a new test that drives
`executor`/`attester` through all four.

Refusal stays the default elsewhere. A flow sequence of plain scalars
(`tags: [a, b]`) still raises: the sequence carrier is opened for the flow
mapping element and nothing else. A `sources` entry admits scalar leaves
only, so 5.1's optional PER-ENTRY `usage_window` is refused -- no nesting
past depth 1 is a security property and it was not spent here; registered
as a conformance gap rather than left as an oversight. A block list may not
mix scalars and mappings, because a consumer reading `entry.get("id")` over
one gets an AttributeError off the first str.

New residual registered: `sources[].resource` is scanned as text (T1) but
never URL-validated. T3's https allowlist cannot reach it without
over-blocking conformant bundles -- 5.1 permits bundle-relative paths and
scope descriptors, and the producers' own golden emits `resource: fixture`.
A consumer that dereferences it must call `validate_resource_url` itself.

Suite 834 -> 859 green. 25 new rows; four pre-existing rows changed because
this release changed the behaviour they pinned, two of them renamed since
their names asserted the old invariant (`exactly_one_route_to_a_mapping`,
`two_keys_per_item_is_where_the_block_list_hard_rejects`). Not "unchanged".
130/130 classes, 6/6 gaps hold, 44 -> 45 limitations, ReDoS 0/152 (the
sweep adds no evidence here -- this change adds no regex and the splitting
is linear). Six version surfaces bumped by hand, no sed. Re-measured alone
after the bump.

No exported surface changed; no detector behaviour and no calibration
changed.
This commit is contained in:
Kjell Tore Guttormsen 2026-09-02 17:21:26 +02:00
commit a965e8ac5b
10 changed files with 555 additions and 105 deletions

View file

@ -4,7 +4,7 @@
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.
**Status of the guard:** `v1.2.0`. Stdlib-only core, framework-agnostic. The
**Status of the guard:** `v1.3.0`. Stdlib-only core, framework-agnostic. The
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
*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
```
As of `v1.2.0`: **130 / 130 defended classes demonstrated (recall 100%)** and **6 /
As of `v1.3.0`: **130 / 130 defended classes demonstrated (recall 100%)** and **6 /
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 (**834
update). The matrix is the single source of truth for the test suite (**859
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
set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb,

View file

@ -3,7 +3,7 @@
**A reusable, minimal, dependency-light defensive layer for LLM *ingestion*
pipelines — the write-time siblings of query-time chatbot guardrails.**
Status: implemented — v1.2.0, exported surface frozen under semver. This document defines what the repo contains
Status: implemented — v1.3.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
`docs/PLAN.md` for the build order).

View file

@ -40,27 +40,31 @@ items; this is the full list, each with the mechanism.
(an injection in a directory listing is caught) rather than path-rejecting the
conformant bundle. A front-end materialising individual uploads keeps the opposite
rule (`allow_reserved=False`): a reserved basename is a listing-shadow and refused.
- **OKF frontmatter is a restricted grammar: the mapping class has exactly one
expressible form.** Gate T2 accepts a line-oriented subset deliberately — full YAML
is a larger parse-attack surface than a write-time gate needs. Flow sequences
(`[a, b]`) and nested mappings are *rejected outright*, which fails secure.
**Three of the four routes to a mapping fail, each on a different rule** — block
(`k:\n sub: v`) on the nested-mapping check, dotted keys (`k.sub: v`) on the key
pattern, and the inline second colon (`k: sub: v`) on the mapping-construct check.
**The fourth, the flow form, is admitted only when every key is on an allowlist**
- **OKF frontmatter is a restricted grammar: a mapping is expressible through
four carriers, all of them key-allowlisted.** Gate T2 accepts a line-oriented
subset deliberately — full YAML is a larger parse-attack surface than a
write-time gate needs. The admitted carriers are the flow mapping as a value
(`generated: { by: x, at: y }`), the flow mapping as a block-list item, the
flow *sequence* of flow mappings (`sources: [{ id: a, resource: x }]`) and the
block sequence of block mappings (SPEC.md §5.1's own form). **The routes that
still fail, each on a different rule:** a top-level block *mapping*
(`k:\n sub: v`) on the nested-mapping check, dotted keys (`k.sub: v`) on the
key pattern, the inline second colon (`k: sub: v`) on the mapping-construct
check, and a flow sequence of plain *scalars* (`tags: [a, b]`) on the `[`
indicator — the sequence carrier is opened for the mapping element and nothing
else. **Every carrier is admitted only when every key is on an allowlist**
(`by`, `at`, `from`, `to`, `id`, `title`, `author`, `usage_count`,
`last_modified` — the keys SPEC.md @ `62432a09` §5.1/§5.2 names inside a mapping)
`last_modified` — the keys SPEC.md @ `62432a09` §5.1/§5.2 names inside a mapping,
plus `resource` and `usage_window` *under `sources` only*, see below)
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
blanket refusal was doing the enforcing. **A block list may not mix scalar items
and mappings** — YAML permits it, but a consumer iterating `sources` and reading
`entry.get("id")` gets an `AttributeError` off the first `str`. 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`**:
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
@ -85,19 +89,40 @@ items; this is the full list, each with the mechanism.
door A/B persist path, so frontmatter that fails secure on import passes
`screen_output` unremarked. The grammar therefore bounds what a consumer can *receive*,
never what a producer can *emit*. Verified identical on 0.2.0 and 0.3.1.
- **An OKF v0.2 concept traverses the external-import path only if its `sources` are
flat.** The wall used to be total: both of v0.2's backward-breaking migration targets
are mappings — `timestamp``generated.at`, and body `# Citations` → a `sources`
block list of mappings — and a consumer measured **0 of 53** upstream concepts
through the gate. The trust and provenance layer now passes in its spec form
(`generated`, `verified` bare or listed, `usage_window`), so `generated.at` is no
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`.
- **`sources` passes in both of its spec carriers; the per-entry `usage_window`
does not.** The wall used to be total: both of v0.2's backward-breaking migration
targets are mappings — `timestamp``generated.at`, and body `# Citations` → a
`sources` block list of mappings — and a consumer measured **0 of 53** upstream
concepts through the gate. `generated.at` stopped being a wall in `1.2.0`;
`sources` stopped being one in `1.3.0`, which admits both the block sequence of
block mappings (SPEC.md §5.1's own example) and the flow sequence of flow
mappings (the form the OKF producers emit, measured 2026-09-02 by two consumers
independently). **`resource` is allowlisted inside a `sources` entry and nowhere
else.** `1.2.0` left it off on the argument that the parser could not tell
`sources[].resource` (§5.1, a citation) from `executor.resource` / `attester.resource`
(§10, a pointer to code to be run — door C). That premise was measured false: the
owning key is in scope at every call site and was simply never threaded through.
It is threaded now, so `executor: [{ resource: skills/run.md }]` and
`attester:\n - resource: …` are refused on the allowlist through *every* carrier,
including the two this opened. **What stays refused: a `usage_window` inside a
`sources` entry.** SPEC §5.1 permits it per entry ("A single entry MAY carry its
own `usage_window`"), and it is a mapping inside a mapping — depth 2, which this
parser admits at no key. A bundle using the per-entry override is refused; the
shared sibling `usage_window` (the §5.1 example's own form) passes. This is a
registered conformance gap, not an oversight: no-nesting-past-depth-1 is a
security property, and spending it was not what the fix was for.
- **`sources[].resource` is scanned as text but never validated as a URL.** T3's
https allowlist inspects the *top-level* `resource` and nothing else. It cannot
be extended to `sources` entries without over-blocking conformant bundles: SPEC
§5.1 explicitly permits a bundle-relative path, a path into `references/`, or a
scope descriptor a consumer cannot follow at all (the OKF producers' own golden
bundle emits `resource: fixture`). So a `sources` entry may carry
`file://`, `javascript:` or any other string; it goes through T1's scan like any
other frontmatter value, and nothing else. **A consumer that dereferences
`sources[].resource` must validate it itself** — `okf.validate_resource_url` is
exported for exactly that. The dangling-or-substituted `executor`/`attester`
pointer question stays out of reach separately: both are top-level block
mappings, a carrier that is still refused.
- **`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
@ -111,10 +136,10 @@ items; this is the full list, each with the mechanism.
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
`generated` correctly as a mapping. **Independent of the mapping-form work
above:** neither `1.2.0`'s flow mapping nor `1.3.0`'s `sources` carriers move
anything 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
purpose is to *name code to be run* (`runtime`, `executor.resource`,