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:
parent
0184df9ed9
commit
a965e8ac5b
10 changed files with 555 additions and 105 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue