feat(guard): wire Doors B and C to the real guard (Phase 2 step 4)

Adds llm_ingestion_guard>=0.2,<0.3 as this library's first and only runtime
dependency, and guard_adapter.py -- the one module that imports it. The flows
themselves are unchanged: they still take an injected gate, and importing the
package still does not import the guard, so a Door A consumer is unaffected by
the dependency's state.

Door B screens the exact bytes it persists. The guard's §6 bookends
(prepare_input -> model -> screen_output) assume a model call in between; this
library makes none, and prepare_input returns prompt-shaped text (sanitized
AND spotlight-fenced with a per-call nonce) that must never reach disk. So the
adapter calls screen_output alone, on the extracted text as it stands, and
hands that same text back -- the verdict is then a statement about the bytes
actually written. This supersedes the plan's "bookends" wording, recorded
there under "Settled during implementation (step 4)".

It follows that the gate refuses rather than repairs: a file carrying an
invisible carrier is rejected, not stripped and persisted. Sanitizing first
would write a document differing invisibly from the operator's file while
source_sha256 still points at the original bytes. Operator decision; the
policy is PRESET_USER_UPLOAD, so any finding at all is held back.

Door C hands the bundle over whole to okf.import_bundle, which resolves the
cross-link graph across concepts. Per-concept reasons are derived from the
scan findings (severity:label) because stamp_concept keeps the disposition and
drops the reason strings behind it.

Assumption B1 closes as a signature smoke test over what the adapters actually
call -- screen_output and okf.import_bundle signatures, the Disposition values
both doors compare by value, the Origin/Channel vocabularies Door C validates,
the result fields read, and the upload preset's shape. prepare_input is not
pinned: drift there cannot reach this library. Behaviour is pinned against the
real scanner too, including the persist-gate proof that a fail-secure fixture
leaves the bundle byte-identical.

B2 closes with it: git+https tag install over anonymously readable HTTPS, no
credential. A direct reference is an install-time channel, not the pin -- the
range stays in pyproject, is satisfied by the tag install today, and resolves
normally once the package index exists. A packaging test enforces that the
guard remains the only runtime dependency (verified by hand-mutation).
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 07:32:45 +02:00
commit 241e00f27a
8 changed files with 578 additions and 38 deletions

View file

@ -65,6 +65,44 @@ No scanning, sanitizing, or quarantine logic is implemented here.
(reserved-file policy differs per consumer and becomes configurable in
Phase 3).
### Settled during implementation (step 4)
- **Door B calls `screen_output` alone, and this supersedes deliverable 3's
"`prepare_input`/`screen_output` bookends".** The bookends assume a model
call between them; this library makes none, and `prepare_input` returns
prompt-shaped text (sanitized *and* spotlight-fenced with a per-call nonce)
that must never reach disk. The adapter therefore screens the extracted
text as it stands and persists that same string, so the verdict is a
statement about the bytes actually written.
- **The gate refuses; it does not repair.** Sanitizing before persisting
would write a document differing invisibly from the operator's file while
`source_sha256` still points at the original bytes. A file carrying an
invisible carrier is rejected instead — the guard's own doctrine is that a
carrier has no legitimate place in a reference file, and this library's
posture everywhere else is fail-fast, never repair. Operator decision.
- **The policy is `PRESET_USER_UPLOAD`** (untrusted tier, quarantine floor):
an inbox drop is an untrusted upload, so any finding at all is held rather
than written. A caller needing another tier injects their own adapter.
- **`guard_adapter.py` is the only module that imports the guard**, and the
package `__init__` does not import it, so a Door A consumer's import path
is unaffected by the dependency's state.
- **Assumption B1 pins what the adapters call, not the whole guard.**
`prepare_input` is dropped from the smoke test: drift there cannot reach
this library. What is pinned: `screen_output` and `okf.import_bundle`
signatures, the `Disposition` values both doors compare against, the
`Origin`/`Channel` vocabularies Door C validates, the result fields the
adapters read, and the upload preset's shape.
- **The pin stays a range; the git URL is an install channel.** A PEP 508
direct reference pins one tag and cannot express `>=0.2,<0.3`, but it is an
install-time channel rather than a dependency declaration: the range is
what `pyproject.toml` carries, it is satisfied by the tag install today,
and it resolves normally once the package index exists (confirmed by the
guard repo, superseding an earlier reading that the range had to go).
- **Door C reasons are derived, not carried.** The guard's `stamp_concept`
keeps a concept's disposition and drops the reason strings behind it, so
the adapter reports `severity:label` per finding — the audit trail actually
available at that seam.
### Settled during implementation (step 5)
- **Verbatim merge, no frontmatter of ours.** A merged concept is written
@ -128,8 +166,8 @@ No scanning, sanitizing, or quarantine logic is implemented here.
| # | Assumption | Test |
|---|---|---|
| B1 | Guard 0.2 API matches the pinned surface (`prepare_input`, `screen_output`, `okf.import_bundle`, disposition enum) | Import-and-signature smoke test that fails on upgrade drift |
| B2 | Guard is installable where this library's CI runs | Resolve the distribution channel with the operator before implementation starts (risk: not yet decided) |
| B1 | Guard 0.2 API matches the pinned surface (`screen_output`, `okf.import_bundle`, disposition enum, origin/channel vocabularies) | Import-and-signature smoke test that fails on upgrade drift (`tests/test_guard_adapter.py`) — CLOSED at step 4 |
| B2 | Guard is installable where this library's CI runs | CLOSED: git+https tag install over anonymously readable HTTPS, no credential; Forgejo package index becomes the durable channel later without a pyproject edit |
| B3 | `html.parser`-based extraction is adequate for v1 | Golden fixtures for representative HTML; anything richer is explicitly out of scope |
| B4 | Guard verdicts are deterministic for fixed input + version | Same fixture run twice → identical `InboxResult` |
@ -156,4 +194,5 @@ No scanning, sanitizing, or quarantine logic is implemented here.
5. Phase 1 golden suite still passes byte-for-byte (no regression from reuse).
6. Grep-gate: `grep -rn "sanitize\|quarantine\|lexicon" src/` shows no local
security reimplementation (guard imports only).
7. `pyproject.toml` runtime dependencies == exactly `llm-ingestion-guard>=0.2,<0.3`.
7. `pyproject.toml` runtime dependencies == exactly `llm-ingestion-guard>=0.2,<0.3`
(automated: `test_the_only_runtime_dependency_is_the_security_boundary`).