The narrowing landed in 736f370 and its prose asserted `0.6.0` in thirteen places
-- README's public front page among them -- while every version surface still read
0.5.0. That is the same defect class the last three commits were spent correcting:
a published number no measurement backs. Two ways out, land it or neutralize the
references; operator chose to cut.
SURFACES MOVED (the eight the 0.5.0 sweep established, plus the ninth verified)
pyproject.toml 0.5.0 -> 0.6.0
__init__.py 0.5.0 -> 0.6.0
CHANGELOG.md [Unreleased] -> [0.6.0], fresh [Unreleased]
README.md badge, `Status: v0.6`, install tag @v0.6.0
SECURITY.md support window `0.5.x` -> `0.6.x`
docs/BRIEF.md `v0.5 (alpha)` -> `v0.6 (alpha)`
docs/ADOPTION-BRIEF.md `v0.5.0` x2, and 717 -> 727 passing
CLAUDE.md `v0.5 (alpha)` -> `v0.6`, plus what 0.6.0 changed
forge description 178 codepoints, carries no version claim -- verified,
not moved. A surface can be checked and stay still.
Measurement provenance is left alone, as in 0.5.0: `tests/test_disposition.py`'s
"0.5.0 axis separation", `disposition.py` and `calibration.py` docstrings,
LIMITATIONS' 0.5.0 reference, every dated claim in docs/PLAN-v1.md. Bumping those
falsifies the record rather than updating it.
MINOR, NOT PATCH: 0.6.0 loosens the upload door. A document whose only finding was
a doc-relative URL attribute on an inactive tag name, or an attribute-less
`<base />`, now WARNs where it was held -- 25 documents in the reference corpus, 2
in each wiki corpus.
VERIFIED BEFORE COMMITTING, NOT AFTER
727 passed; coverage 128/128 recall, 6/6 documented gaps hold
docs/LIMITATIONS.md: 33 items, README says 33
rawhtml-census PRODUCTION row equals `A + base-url` on all three populations --
the shipped predicate measured, not a hypothesis about it
redos-sweep: 0 candidates of 152 patterns
docs/fp-sweep.py still imports the private names it reaches into
no tracked file carries a stale current-state version claim
Still to prove before the tag: `git show` over this commit's README, and a clean
clone install at this sha.
154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
"""llm-ingestion-guard — a write-time defensive layer for LLM ingestion pipelines.
|
|
|
|
Query-time guardrails guard the answer; this library guards the *artifact*. It
|
|
packages the ingestion-side security contract — sanitize -> fence -> tool-less
|
|
quarantined transform -> per-stage capability isolation -> scan output before
|
|
persist -> fail-secure — as composable, stdlib-first, framework-agnostic code.
|
|
|
|
The library never makes the model call itself (no SDK is imported by the core),
|
|
so the public surface is a **toolkit plus two bookends** around the caller's
|
|
tool-less transform (BRIEF §6):
|
|
|
|
prepared = prepare_input(untrusted_content) # §6 steps 1-2: sanitize + fence
|
|
output = your_model(prepared.fenced) # §6 step 3: tool-less, caller's job
|
|
decision = screen_output(output, policy) # §6 steps 6-7: scan + dispose
|
|
|
|
if decision.disposition is Disposition.FAIL_SECURE:
|
|
raise SystemExit # halt + alert; never persist (§6 steps 7-8)
|
|
|
|
The individual detectors (:func:`sanitize`, :func:`scan_lexicon`,
|
|
:func:`scan_output`, ...), the contract asserters (:func:`assert_tool_less`,
|
|
:func:`scoped_env`, ...) and the disposition machinery are all exported for
|
|
pipelines that compose the checklist themselves. See ``docs/PLAN.md`` for the
|
|
build order and ``docs/BRIEF.md`` §6 for the contract this wiring encodes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .report import Finding, Report, Severity, Source, severity_rank
|
|
from .sanitize import sanitize, SanitizeResult
|
|
from .entropy import scan_entropy, EntropyResult, DecodedBlob
|
|
from .lexicon import scan_lexicon, load_lexicon, LexiconPattern
|
|
from .fence import fence, FenceResult
|
|
from .neutralize import neutralize, NeutralizeResult
|
|
from .active_content import scan_active_content
|
|
from .output import scan_output, scan_secret_egress
|
|
from .disposition import (
|
|
decide,
|
|
guard,
|
|
Policy,
|
|
Trust,
|
|
Provenance,
|
|
Risk,
|
|
Disposition,
|
|
DispositionResult,
|
|
DEFAULT_ACTION_MAP,
|
|
PRESET_TRUSTED_SOURCE,
|
|
PRESET_USER_UPLOAD,
|
|
)
|
|
from .contract import (
|
|
assert_tool_less,
|
|
assert_credential_allowlist,
|
|
assert_within_input_cap,
|
|
credential_env_names,
|
|
scoped_env,
|
|
ContractViolation,
|
|
OversizeInputError,
|
|
)
|
|
from .grounding import (
|
|
SourceGroundingCheck,
|
|
no_grounding_check,
|
|
DEFAULT_GROUNDING_CHECK,
|
|
)
|
|
from . import okf
|
|
|
|
__version__ = "0.6.0"
|
|
|
|
|
|
# --- §6 bookends: the two library-side halves around the transform ---------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PreparedInput:
|
|
"""Untrusted content made ready for a tool-less transform (§6 steps 1-2).
|
|
|
|
``fenced`` is the sanitized, spotlight-fenced text to hand the model.
|
|
``nonce`` is the per-call fence delimiter — a caller may check for it in the
|
|
output to detect a fence breakout. ``report`` merges the sanitize and fence
|
|
findings so the caller can gate on the *input* too, not only the output.
|
|
"""
|
|
|
|
fenced: str
|
|
nonce: str
|
|
report: Report
|
|
|
|
|
|
def prepare_input(text: str, source: Source = Source.INPUT) -> PreparedInput:
|
|
"""Sanitize then fence untrusted ``text`` (BRIEF §6 steps 1-2).
|
|
|
|
Strips carrier classes first (:func:`sanitize`), then spotlight-fences the
|
|
cleaned payload in a randomized per-call delimiter (:func:`fence`) — sanitize
|
|
*before* fence so a carrier can never smuggle a forged delimiter. The merged
|
|
report carries both steps' findings; ``prepare_input`` itself renders no
|
|
disposition (design principle 4 — the caller decides).
|
|
"""
|
|
sanitized = sanitize(text, source)
|
|
fenced = fence(sanitized.text, source)
|
|
report = Report()
|
|
report.extend(sanitized.report.findings)
|
|
report.extend(fenced.report.findings)
|
|
return PreparedInput(fenced=fenced.text, nonce=fenced.nonce, report=report)
|
|
|
|
|
|
def screen_output(
|
|
text: str,
|
|
policy: Policy,
|
|
*,
|
|
provenance: Provenance = Provenance.PROSE,
|
|
transform_failed: bool = False,
|
|
) -> DispositionResult:
|
|
"""Scan the emitted ``text`` and dispose it, failing *closed* (§6 steps 6-7).
|
|
|
|
Runs :func:`scan_output` (lexicon + entropy + decode-and-rescan + secret
|
|
egress) under :func:`guard`, so a scanner that errors on crafted input yields
|
|
``FAIL_SECURE`` rather than an auto-persist — an un-scannable artifact is
|
|
never committed. ``transform_failed=True`` (the caller's model call raised or
|
|
fell back) plus any finding is treated as a probable forced-fallback attack
|
|
and also halts, regardless of trust tier.
|
|
"""
|
|
return guard(
|
|
lambda: scan_output(text, source=Source.OUTPUT),
|
|
policy,
|
|
provenance=provenance,
|
|
transform_failed=transform_failed,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"__version__",
|
|
# shared types
|
|
"Finding", "Report", "Severity", "Source", "severity_rank",
|
|
# input-side detectors + result types
|
|
"sanitize", "SanitizeResult",
|
|
"scan_entropy", "EntropyResult", "DecodedBlob",
|
|
"scan_lexicon", "load_lexicon", "LexiconPattern",
|
|
"fence", "FenceResult",
|
|
"neutralize", "NeutralizeResult",
|
|
# output-side
|
|
"scan_output", "scan_secret_egress", "scan_active_content",
|
|
# disposition — `Risk` is the assessment axis, `Disposition` the action
|
|
"decide", "guard", "Policy", "Trust", "Provenance",
|
|
"Risk", "Disposition", "DispositionResult", "DEFAULT_ACTION_MAP",
|
|
"PRESET_TRUSTED_SOURCE", "PRESET_USER_UPLOAD",
|
|
# contract asserters
|
|
"assert_tool_less", "assert_credential_allowlist",
|
|
"credential_env_names", "scoped_env", "ContractViolation",
|
|
"assert_within_input_cap", "OversizeInputError",
|
|
# grounding seam
|
|
"SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK",
|
|
# §6 bookends
|
|
"prepare_input", "screen_output", "PreparedInput",
|
|
# OKF adapter (v0.2) — the format-specific layer, as its own namespace
|
|
"okf",
|
|
]
|