feat(inbox): two-stage upload front-end — text formats (stage 2a)
Realistic-upload showcase (PLAN §247), first slice. A stage-1 front-end reads
dropped files and materializes them into an OKF bundle {concept_path: text} +
provenance; receive() wires extract -> import_bundle -> verdict. This slice
covers .txt/.md (stdlib only); .zip/.csv/folder/.docx/.pptx follow.
- Front-end lives in tests/ (showcase/dev-scoped), core stays stdlib-only:
dependencies=[] untouched; python-docx/python-pptx added to the [dev] extra
(used from stage 2d/2e), never a public [extract] extra (not v1 per PLAN).
- .txt injection -> guard T1 -> REJECT; clean .txt -> ADMIT; a dropped .md keeps
its frontmatter so a dangerous value -> T2 REJECT; an index.* upload
materializes onto the reserved uploads/index.md -> T4 REJECT.
- Detach-proof: neuter extraction to an empty bundle -> the poisoned upload
ADMITs, proving the verdict depends on extraction carrying the payload.
Tests 282 -> 288.
This commit is contained in:
parent
0061c42f0b
commit
24e57ca10b
3 changed files with 189 additions and 1 deletions
103
tests/inbox_frontend.py
Normal file
103
tests/inbox_frontend.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""OKF inbox front-end — stage 1 of the two-stage upload showcase (PLAN §247).
|
||||
|
||||
Reads the files a human actually drops into an inbox and *materializes* them into
|
||||
an OKF bundle ``{concept_path: document_text}`` with provenance, so the stage-2
|
||||
guard (:func:`llm_ingestion_guard.okf.import_bundle`) can validate every concept.
|
||||
This stage owns the container/format threats; the guard owns the text/structural
|
||||
threats.
|
||||
|
||||
**Placement.** This lives in the test tree, not ``src/``: the extraction parsers
|
||||
are showcase/dev-scoped (``python-docx``/``python-pptx`` in the ``dev`` extra,
|
||||
never core ``dependencies``), and the shippable core stays stdlib-only. It is an
|
||||
in-repo demonstration a consumer reads and adapts, not v1 shipped code.
|
||||
|
||||
Slice 2a covers the text formats — ``.txt`` and ``.md`` (stdlib only). ``.zip``
|
||||
(zip-slip / zip-bomb), ``.csv`` (formula injection), folders, ``.docx`` and
|
||||
``.pptx`` land in later slices.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from llm_ingestion_guard.okf import import_bundle, Origin, Channel, BundleResult
|
||||
from llm_ingestion_guard.disposition import Disposition
|
||||
|
||||
|
||||
# Aggregate disposition -> inbox verdict. Fail-secure by default: a front-end
|
||||
# refusal (a container threat the guard never sees) is itself a REJECT.
|
||||
_VERDICT = {
|
||||
Disposition.WARN: "ADMIT",
|
||||
Disposition.QUARANTINE_REVIEW: "HOLD",
|
||||
Disposition.FAIL_SECURE: "REJECT",
|
||||
}
|
||||
|
||||
# Where materialized uploads live inside the bundle. An upload named ``index.*``
|
||||
# thus lands on the reserved ``uploads/index.md`` and is refused by the path gate.
|
||||
_MATERIALIZE_PREFIX = "uploads"
|
||||
|
||||
# The text formats this slice reads directly (no parser dependency).
|
||||
_TEXT_SUFFIXES = {".txt", ".md"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provenance:
|
||||
"""Where one materialized concept came from — the audit record (brief §6)."""
|
||||
|
||||
concept_path: str
|
||||
source_name: str
|
||||
source_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboxExtract:
|
||||
"""The stage-1 output: the OKF bundle, its provenance, and front-end refusals.
|
||||
|
||||
``rejected`` holds ``(source_name, reason)`` for drops the front-end refuses
|
||||
outright (a container threat the guard never gets to see, e.g. a zip bomb).
|
||||
Empty for the text-format slice.
|
||||
"""
|
||||
|
||||
bundle: dict
|
||||
provenance: tuple
|
||||
rejected: tuple
|
||||
|
||||
|
||||
def _materialize_path(source_name: str) -> str:
|
||||
"""Assign an OKF concept path to a dropped file: ``<prefix>/<stem>.md``."""
|
||||
stem = Path(source_name).stem
|
||||
return f"{_MATERIALIZE_PREFIX}/{stem}.md"
|
||||
|
||||
|
||||
def extract_inbox(paths) -> InboxExtract:
|
||||
"""Read dropped files and materialize them into an OKF bundle + provenance.
|
||||
|
||||
``paths`` is an iterable of file paths. Each ``.txt`` / ``.md`` becomes one
|
||||
concept; a ``.md`` keeps its OKF frontmatter verbatim, a ``.txt`` becomes a
|
||||
body-only concept. Non-text suffixes are not handled in this slice.
|
||||
"""
|
||||
bundle: dict = {}
|
||||
provenance: list = []
|
||||
for path in paths:
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
if suffix not in _TEXT_SUFFIXES:
|
||||
raise ValueError(f"unsupported upload format in this slice: {path.name!r}")
|
||||
concept_path = _materialize_path(path.name)
|
||||
bundle[concept_path] = path.read_text(encoding="utf-8")
|
||||
provenance.append(
|
||||
Provenance(concept_path, path.name, suffix.lstrip("."))
|
||||
)
|
||||
return InboxExtract(bundle, tuple(provenance), ())
|
||||
|
||||
|
||||
def receive(paths) -> tuple[InboxExtract, BundleResult, str]:
|
||||
"""The full two-stage inbox: extract & materialize, then guard, then verdict.
|
||||
|
||||
Returns ``(extracted, guard_result, verdict)``. A front-end refusal forces a
|
||||
REJECT regardless of the guard's aggregate — the guard never saw that drop.
|
||||
"""
|
||||
extracted = extract_inbox(paths)
|
||||
result = import_bundle(extracted.bundle, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC)
|
||||
verdict = "REJECT" if extracted.rejected else _VERDICT[result.disposition]
|
||||
return extracted, result, verdict
|
||||
Loading…
Add table
Add a link
Reference in a new issue