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:
parent
f10fc60de2
commit
241e00f27a
8 changed files with 578 additions and 38 deletions
|
|
@ -5,24 +5,28 @@ deterministic materialization -> index), a bundle inbox converting common
|
|||
file types to OKF concepts, and import of external OKF bundles.
|
||||
|
||||
Security is owned by llm-ingestion-guard, never reimplemented here. Note
|
||||
what that does and does not mean today: Door A is UNGATED. It has zero
|
||||
runtime dependencies and calls no guard function on the way to disk. A
|
||||
caller that materializes external or otherwise untrusted content is
|
||||
responsible for gating it -- via guard's okf.import_bundle for received
|
||||
bundles, or prepare_input/screen_output around extracted text. Do not read
|
||||
"security is delegated" as "safe by default": materialize_bundle writes
|
||||
what it is given. The guard-calling persist gates arrive with Doors B and C.
|
||||
what that does and does not mean today: Door A is UNGATED. It calls no guard
|
||||
function on the way to disk, so a caller that materializes external or
|
||||
otherwise untrusted content is responsible for gating it. Do not read
|
||||
"security is delegated" as "safe by default": materialize_bundle writes what
|
||||
it is given.
|
||||
|
||||
Doors B and C are gated by an INJECTED adapter, and `guard_adapter` is the
|
||||
one this library ships over the real guard -- the only module here that
|
||||
imports it. Importing this package does not import the guard; a Door A
|
||||
consumer keeps working whatever state the dependency is in.
|
||||
|
||||
Door A (spec-based ingestion) public surface: materialize_bundle plus the
|
||||
typed error hierarchy rooted in IngestError.
|
||||
|
||||
Door B (bundle inbox) public surface: process_inbox plus its result types.
|
||||
Its persist gate is INJECTED -- the caller supplies a Gate adapter over
|
||||
llm-ingestion-guard and process_inbox obeys the verdict; the library imports
|
||||
no guard function itself and makes no security decision of its own.
|
||||
llm-ingestion-guard (guard_adapter.inbox_gate is the shipped one) and
|
||||
process_inbox obeys the verdict, making no security decision of its own.
|
||||
|
||||
Door C (external bundle import) public surface: import_bundle plus its result
|
||||
types. Its gate is injected the same way, over the guard's okf.import_bundle:
|
||||
types. Its gate is injected the same way, over the guard's okf.import_bundle
|
||||
(guard_adapter.import_gate is the shipped one):
|
||||
the caller declares origin and channel at the door, the gate assesses every
|
||||
concept, and only concepts clearing the non-blocking floor are merged. Merged
|
||||
concepts are written verbatim -- ownership is proven by content identity
|
||||
|
|
|
|||
101
src/llm_ingestion_okf/guard_adapter.py
Normal file
101
src/llm_ingestion_okf/guard_adapter.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""The only module in this library that imports `llm-ingestion-guard`.
|
||||
|
||||
Doors B and C take an INJECTED gate so the flows stay dependency-free and
|
||||
testable; this module is the adapter a caller injects when the gate should be
|
||||
the real guard. It translates in one direction only — guard verdict in, the
|
||||
door's decision type out — and takes no decision of its own. Everything a
|
||||
verdict depends on happens inside the guard.
|
||||
|
||||
**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. Two consequences, both deliberate:
|
||||
|
||||
- an invisible carrier is REFUSED rather than stripped-and-persisted. The
|
||||
guard's own doctrine is that a carrier has no legitimate place in a
|
||||
reference file, and sanitizing before persisting would write a document
|
||||
that differs invisibly from the operator's file while `source_sha256`
|
||||
still points at the original bytes. This library validates and refuses;
|
||||
it does not repair.
|
||||
- the verdict is a statement about the persisted document, because the
|
||||
screened string and the written string are the same string.
|
||||
|
||||
The policy is `PRESET_USER_UPLOAD`: an inbox drop is an untrusted upload, so
|
||||
any finding at all is held for review rather than written. A caller who needs
|
||||
another tier writes their own three-line adapter — that is what the injected
|
||||
seam is for.
|
||||
|
||||
**Door C hands the bundle over whole.** `okf.import_bundle` resolves the
|
||||
cross-link graph across concepts, so gating them one at a time would throw
|
||||
half the gate away. The adapter maps each `ConceptResult` to an
|
||||
`ImportDecision` and returns the guard's log body unwritten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from llm_ingestion_guard import PRESET_USER_UPLOAD, screen_output
|
||||
from llm_ingestion_guard import okf as guard_okf
|
||||
|
||||
from .errors import MaterializationError
|
||||
from .importer import BundleDecision, ImportDecision
|
||||
from .inbox import GateDecision
|
||||
|
||||
__all__ = ["import_gate", "inbox_gate"]
|
||||
|
||||
|
||||
def inbox_gate(text: str) -> GateDecision:
|
||||
"""Door B's persist gate over the real guard (a `Gate`).
|
||||
|
||||
`disposition` is the guard's `Disposition` VALUE, carried across as a
|
||||
plain string so the flow never imports the enum, and `reasons` is the
|
||||
guard's audit trail verbatim.
|
||||
"""
|
||||
decision = screen_output(text, PRESET_USER_UPLOAD)
|
||||
return GateDecision(
|
||||
sanitized_text=text,
|
||||
disposition=str(decision.disposition.value),
|
||||
reasons=tuple(str(reason) for reason in decision.reasons),
|
||||
)
|
||||
|
||||
|
||||
def import_gate(bundle: dict[str, str], *, origin: str, channel: str) -> BundleDecision:
|
||||
"""Door C's persist gate over `okf.import_bundle` (an `ImportGate`).
|
||||
|
||||
`origin`/`channel` cross the seam as strings and are converted to the
|
||||
guard's enums HERE, because the guard derives trust from `origin` by enum
|
||||
identity: a value it does not recognise would arrive as a plain string,
|
||||
miss the identity check, and be silently classified untrusted. Refusing an
|
||||
unrecognised value is not a trust decision, it is a refusal to guess at
|
||||
one — the same code Door C's own validation raises.
|
||||
|
||||
`reasons` is DERIVED from each concept's scan findings, not carried
|
||||
verbatim: the guard's per-concept `stamp_concept` keeps the disposition
|
||||
and drops the reason strings that produced it, so the findings are the
|
||||
audit trail actually available at this seam.
|
||||
"""
|
||||
try:
|
||||
guard_origin = guard_okf.Origin(origin)
|
||||
guard_channel = guard_okf.Channel(channel)
|
||||
except ValueError as exc:
|
||||
raise MaterializationError(
|
||||
f"origin={origin!r} channel={channel!r} is outside the guard's vocabulary — "
|
||||
"refusing to carry a provenance declaration it would not recognise "
|
||||
"(it decides trust from these values)",
|
||||
code="import_provenance_invalid",
|
||||
) from exc
|
||||
|
||||
result = guard_okf.import_bundle(dict(bundle), origin=guard_origin, channel=guard_channel)
|
||||
concepts = tuple(
|
||||
ImportDecision(
|
||||
path=str(concept.path),
|
||||
disposition=str(concept.disposition.value),
|
||||
error=None if concept.error is None else str(concept.error),
|
||||
reasons=tuple(
|
||||
f"{finding.severity.value}:{finding.label}" for finding in concept.report.findings
|
||||
),
|
||||
)
|
||||
for concept in result.concepts
|
||||
)
|
||||
return BundleDecision(concepts=concepts, log=str(result.log()))
|
||||
Loading…
Add table
Add a link
Reference in a new issue