fix(assets): bound what the run pays, not what the document claims (0.10.1)

A second independent review read `230d1cb` -- the commit that closed the
`v0.10.0` review's two MAJOR findings -- and found one of them open. The
bound read `/Width` and `/Height`, which an untrusted document writes, while
`get_data()` pays for the stream beside them; `/Length` is the COMPRESSED
length and the two numbers are independent.

Re-measured here on `ed8d9d7` before anything changed, in its own
interpreter: a 408 516-byte PDF declaring 1x1 and carrying 400 MB of deflated
zeros was CARRIED, no rejection, 891 904 000 B peak RSS. After: 0 carried,
`asset_too_large`, 57 065 472 B. At 1,2 GB of zeros, 2 436 MB -> 64 569 344 B
-- the cost no longer scales with the bomb. End to end through the CLI with
the shipped defaults: 838 000 640 B and an asset written -> exit 0,
79 650 816 B, `0 carried of 1 found`, no `assets/`.

Three numbers are bounded now, not one: what a container DECLARES, what a
carried FILE measures (`read_image`, so a 49 MP PNG of 47 705 bytes is not
passed on to a consumer), and what a PDF stream DECOMPRESSES to
(`assets.inflated_size`, a chunk at a time, output discarded, before
`get_data()`). The limit is stated rather than implied: the stream
measurement runs where `FlateDecode` is the first filter and the document is
not encrypted; every other chain is a check on the decoded length AFTER the
decode, a counted refusal and not a bounded one.

A non-positive declared dimension is `asset_size_invalid`, its own code,
raised before the stream is read. `-1 x 40000000000` is a NEGATIVE pixel
count, under which every `>` bound read as satisfied, so the check returned
silently and the refusal arrived from `encode_png` as
`asset_samples_invalid`. Its own code because a publisher shipping a picture
bigger than this package carries and a dictionary written to be read wrong
are different facts about a document.

Two smaller findings in the line that says what is missing, both introduced
by the first fix: the address was written twice, once bare, and a linkifying
renderer autolinks a bare URL -- written once now, in one code span; and
`label` became a dead parameter, so the figure's caption was dropped, a
regression against 0.10.0. It is written again in the `-- <label>` form a
carried pointer uses.

Version bumped to 0.10.1 across all ten places. Nine were unbound and stale:
four README install lines naming the previous release, two prose lines, the
"current tag" entry, `uv.lock`, and a CHANGELOG whose 0.10.1 content sat
under `[Unreleased]`. Two new packaging tests bind them to `__version__`, and
the README's guard tag to `[tool.uv.sources]`.

Every test was red first. The fate of every image is identical with and
without the new bound on three K2 PDFs carrying 800 images (464/464, 311/311
with the same 12 rejections, 25/25), and the second inflate is below the
noise floor there. 0 shipped artifacts move: no bundle under `examples/`,
`skills/` or `tests/fixtures/` carries an image pointer at all, measured
against a known-positive control.

`asset_too_large` was undocumented in the error registry; both codes are
there now. `tools/okf_accounting_gate.py` gains the new code in its closed
list -- one string, no behaviour change, stated because that file belongs to
another order.

Suite 2141 passed / 1 skipped, ruff + format + mypy --strict clean.
Report: docs/2026-09-18-bildestien-holder-0-10-1.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 13:41:18 +02:00
commit 0f308c1f56
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
15 changed files with 842 additions and 81 deletions

View file

@ -10,7 +10,7 @@ types](#supported-file-types) lists each one with the evidence behind it.
Python 3.10+ and [uv](https://docs.astral.sh/uv/). One line:
```sh
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.0"
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.1"
```
## Use it
@ -120,11 +120,11 @@ Neither this package nor the guard it depends on is on a package index yet, so
both install by direct reference. With uv, one command resolves both:
```sh
uv pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.0"
uv pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.1"
```
uv resolves the guard on its own, because it reads the `[tool.uv.sources]`
entry in the `pyproject.toml` **of the tag it is installing**, and `v0.10.0`
entry in the `pyproject.toml` **of the tag it is installing**, and `v0.10.1`
points that entry at `llm-ingestion-guard` `v1.4.0`. Use `uv tool install`
instead of `uv pip install` when you want the `okf` command on `PATH` without an
active virtualenv — that is the form the first screen shows.
@ -135,10 +135,10 @@ With plain pip, the transitive git dependency does not resolve on its own —
```sh
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v1.4.0"
pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.0"
pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.1"
```
The guard tag is paired to the okf tag, not to this branch. `v0.10.0` declares
The guard tag is paired to the okf tag, not to this branch. `v0.10.1` declares
`llm-ingestion-guard>=1.2,<2.0`, which `v1.4.0` satisfies; the pairing above is
read off that tag's own `[tool.uv.sources]`, not off this branch. Reading a pin
off `main` and installing it against an older okf tag is the one combination
@ -149,7 +149,16 @@ that fails.
These are not install lines. They record what each earlier tag was, so a reader
who meets one in an older document knows what they are looking at.
- `v0.10.0` — the current tag: a bundle carries the IMAGES its sources
- `v0.10.1` — the current tag: the image path of `v0.10.0`, with the two
defects an independent review found in it closed. A remote `<img src>` or
`xlink:href` is inert text with the address in one code span, never a live
markdown image link, and no longer loses the figure's caption. An image is
bounded in three places rather than one: the size a container DECLARES, the
size a carried file has, and — new in this tag — what the stream behind a
PDF image actually DECOMPRESSES to, which is an independent number. A
declared size that is not positive is refused with its own code,
`asset_size_invalid`, before the stream is read.
- `v0.10.0` — a bundle carries the IMAGES its sources
declare. Five readers place them (`pdf`, the converter's office rows,
`html`, `xml`), `assets/` at the bundle root holds the bytes under a
content-addressed name, and the concept carries a two-line pointer where the
@ -299,32 +308,54 @@ in must not read like a bundle of documents that had none.
text. The pointer block passes the gate like any other body line, and the file
beside it passes nothing. `log.md` says so on every run that carries one.
**A size CEILING, read off the same corpora (0.10.1).** An image whose
DECLARED size is over `MAX_IMAGE_PIXELS` (40 000 000 pixels) or
`MAX_IMAGE_BYTES` (256 MiB of samples) is refused with `asset_too_large`,
counted like every other refusal, and never decoded. The largest image in the
43-document reference corpus is 4 515 x 4 128 (18.6 MP) and the largest of
R761's 109 pictures is 2 072 x 656 (1.4 MP), so the bound is an order of
magnitude above anything measured.
**A size CEILING, read off the same corpora (0.10.1).** An image over
`MAX_IMAGE_PIXELS` (40 000 000 pixels) or `MAX_IMAGE_BYTES` (256 MiB) is
refused with `asset_too_large`, counted like every other refusal. The largest
image in the 43-document reference corpus is 4 515 x 4 128 (18.6 MP) and the
largest of R761's 109 pictures is 2 072 x 656 (1.4 MP), so the bound is an
order of magnitude above anything measured.
It exists because a few kilobytes can declare an enormous picture: a 9.6 KB
PDF declaring 3 000 x 3 000 grayscale zeros took 83 MB of peak RSS, a 63 KB
one declaring 8 000 x 8 000 took 276 MB, and the cost is linear in the pixel
count, so one document could take a whole batch build with it — before any
gate, because the guard never sees image bytes. The bound is checked on what
the container declares, before anything is decompressed, on the two paths that
decode: a PDF image's raw samples and an inline `data:` URI. An image file
carried verbatim is not decoded at all, so its cost is its own file size.
gate, because the guard never sees image bytes.
**Three numbers are bounded, not one, because a claim is not a cost.** What a
container DECLARES (`/Width` x `/Height`, a PNG header, a `data:` payload's
encoded length) is read before anything is decoded. What a carried FILE
measures is read the same way — this package never decodes such a file, so it
pays nothing for it, but writing a 7 000 x 7 000 PNG of 47 705 bytes into a
bundle would hand the consumer the same bomb with `7000x7000 px` printed
beside it. And what a PDF image's STREAM decompresses to is measured before it
is held, a chunk at a time and discarded, because `/Length` is the COMPRESSED
length and a dictionary declaring 1x1 may hang 400 MB of deflated zeros off
it. Measured: that document is 408 516 bytes and cost 892 MB of peak RSS with
only the declared size bounded; with the stream bounded it is refused at
54 MB, and a three-times-larger bomb costs 62 MB rather than 2 436 MB.
**What the stream bound does NOT reach**, stated because the difference
matters: it runs where `FlateDecode` is the first filter and the document is
not encrypted. Any other filter chain is caught by a check on the decoded
length AFTER the decode, which makes it a counted refusal rather than a
bounded one — the memory is spent and then the picture is dropped.
**A declared size that is not a size** — a zero or negative `/Width` or
`/Height` — is refused with its own code, `asset_size_invalid`, before the
stream is read. Distinct from `asset_too_large` on purpose: one is a publisher
shipping a picture bigger than this package carries, the other is a dictionary
written wrong or written to be read wrong.
<!-- asset-max-pixels: 40000000 -->
**A remote reference is INERT (0.10.1).** `<img src="https://...">` and an STS
`xlink:href` to an address off this machine are written as text with the
address in a code span, never as `![...](https://...)`. Extraction opens no
socket, but a markdown renderer or an agent that fetches what it renders does,
which would turn "this bundle was opened" into a beacon to whoever wrote the
document. The address is still stated, because a reader has to know what stood
there.
address in one code span, never as `![...](https://...)` and never as a bare
URL a linkifying renderer would autolink. Extraction opens no socket, but a
markdown renderer or an agent that fetches what it renders does, which would
turn "this bundle was opened" into a beacon to whoever wrote the document. The
address is still stated, and so is the figure's caption, because a reader has
to know what stood there.
**`images: N` in a concept counts POINTER BLOCKS, not unique pictures.** One
image referenced twelve times in one concept is `images: 12` and one file in
@ -877,7 +908,7 @@ not need this repository — the first line installs the command, the second
builds the bundle and writes a skill beside it, the third asks.
```sh
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.0"
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.10.1"
okf project ~/my-documents
claude
```