feat(disposition): source-trust + provenance gate — WARN|QUARANTINE|FAIL_SECURE, compound + fail-closed (TDD) [skip-docs]
This commit is contained in:
parent
19981623f5
commit
409b847c42
2 changed files with 452 additions and 0 deletions
221
src/llm_ingestion_guard/disposition.py
Normal file
221
src/llm_ingestion_guard/disposition.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""disposition — turn a Report (plus source-trust context) into a gate decision.
|
||||
|
||||
This is the *caller-side* decision layer (design principle 4): detectors report
|
||||
findings; this module maps a :class:`~llm_ingestion_guard.report.Report` to one
|
||||
of three dispositions — ``WARN`` | ``QUARANTINE_REVIEW`` | ``FAIL_SECURE`` —
|
||||
under a source-trust :class:`Policy`. It imposes no blocking of its own; a
|
||||
pipeline decides whether to honour a ``FAIL_SECURE``.
|
||||
|
||||
Two BRIEF principles drive the policy:
|
||||
|
||||
* **§4.6 — fail-secure under compound signals.** A scan hit together with a
|
||||
transform failure is treated as a probable forced-fallback attack and halts.
|
||||
Several weaker findings escalate one tier. And when the scanner itself errors,
|
||||
:func:`guard` fails *closed* — an un-scannable artifact is never persisted.
|
||||
* **§4.7 — disposition scales with trust.** The same finding does not carry the
|
||||
same disposition for every source. Source-level trust (:class:`Trust`) and
|
||||
intra-document provenance (:class:`Provenance`) both downgrade a region: a
|
||||
spoofed ``<system>`` block inside a code fence is a likelier real payload than
|
||||
the same string in authored prose. Invisible carriers and CRITICAL findings
|
||||
are the exception — they have no legitimate place and block in any tier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .report import Report, Severity, severity_rank
|
||||
|
||||
|
||||
class Disposition(str, Enum):
|
||||
"""The gate decision, ordered by :data:`_DISPOSITION_RANK`."""
|
||||
|
||||
WARN = "warn" # non-blocking floor: proceed, logged
|
||||
QUARANTINE_REVIEW = "quarantine_review" # hold for human review
|
||||
FAIL_SECURE = "fail_secure" # halt; never persist
|
||||
|
||||
|
||||
class Trust(str, Enum):
|
||||
"""Source-level trust (BRIEF §4.7)."""
|
||||
|
||||
TRUSTED = "trusted" # pinned, reputable, single-author (e.g. a changelog)
|
||||
UNTRUSTED = "untrusted" # community / scraped / user-generated
|
||||
|
||||
|
||||
class Provenance(str, Enum):
|
||||
"""Intra-document provenance of the scanned region (BRIEF §4.7)."""
|
||||
|
||||
PROSE = "prose" # authored, en-locale prose — the highest-trust surface
|
||||
CODE_FENCE = "code_fence" # inside a code sample — a low-trust surface
|
||||
LOCALIZED = "localized" # a non-English string — a low-trust surface
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
"""A named source-trust policy. See the ``PRESET_*`` constants."""
|
||||
|
||||
trust: Trust
|
||||
quarantine_default: bool = False # any finding -> at least QUARANTINE_REVIEW
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispositionResult:
|
||||
"""The decision plus an auditable trail of which rules fired."""
|
||||
|
||||
disposition: Disposition
|
||||
reasons: tuple[str, ...]
|
||||
max_severity: Optional[Severity]
|
||||
|
||||
|
||||
# Invisible carriers have no legitimate place in a reference file: they block in
|
||||
# any tier regardless of trust or provenance (BRIEF §4.7).
|
||||
_CARRIER_LABELS = frozenset({
|
||||
"sanitize:zero-width",
|
||||
"sanitize:bidi-override",
|
||||
"sanitize:unicode-tag",
|
||||
})
|
||||
|
||||
_DISPOSITION_RANK = {
|
||||
Disposition.WARN: 0,
|
||||
Disposition.QUARANTINE_REVIEW: 1,
|
||||
Disposition.FAIL_SECURE: 2,
|
||||
}
|
||||
|
||||
|
||||
def _more_severe(a: Disposition, b: Disposition) -> Disposition:
|
||||
return a if _DISPOSITION_RANK[a] >= _DISPOSITION_RANK[b] else b
|
||||
|
||||
|
||||
def _escalate(disposition: Disposition) -> Disposition:
|
||||
if disposition is Disposition.WARN:
|
||||
return Disposition.QUARANTINE_REVIEW
|
||||
return Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
def _carrier_label(report: Report) -> Optional[str]:
|
||||
return next((f.label for f in report.findings if f.label in _CARRIER_LABELS), None)
|
||||
|
||||
|
||||
def _is_compound(report: Report) -> bool:
|
||||
"""Two or more findings at MEDIUM+ — weaker signals that escalate together."""
|
||||
strong = sum(
|
||||
1 for f in report.findings
|
||||
if severity_rank(f.severity) >= severity_rank(Severity.MEDIUM)
|
||||
)
|
||||
return strong >= 2
|
||||
|
||||
|
||||
def decide(
|
||||
report: Report,
|
||||
policy: Policy,
|
||||
*,
|
||||
provenance: Provenance = Provenance.PROSE,
|
||||
transform_failed: bool = False,
|
||||
) -> DispositionResult:
|
||||
"""Map ``report`` to a :class:`Disposition` under ``policy``.
|
||||
|
||||
Rules apply in fail-secure precedence order; the most severe outcome wins.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
max_sev = report.max_severity()
|
||||
|
||||
# Overlay A — compound forced-fallback (§4.6): a scan hit plus a failed
|
||||
# transform is a probable forced-fallback attack. Overrides everything.
|
||||
if transform_failed and report.found:
|
||||
reasons.append("compound-forced-fallback: transform failed with active findings")
|
||||
return DispositionResult(Disposition.FAIL_SECURE, tuple(reasons), max_sev)
|
||||
|
||||
# Any-tier exceptions (§4.7): invisible carriers and CRITICAL findings block
|
||||
# regardless of trust or provenance.
|
||||
carrier = _carrier_label(report)
|
||||
if carrier is not None:
|
||||
reasons.append(f"any-tier: invisible carrier ({carrier})")
|
||||
return DispositionResult(Disposition.FAIL_SECURE, tuple(reasons), max_sev)
|
||||
if max_sev is Severity.CRITICAL:
|
||||
reasons.append("any-tier: CRITICAL finding")
|
||||
return DispositionResult(Disposition.FAIL_SECURE, tuple(reasons), max_sev)
|
||||
|
||||
# Effective low-trust: an untrusted source, or a low-trust region within an
|
||||
# otherwise-trusted document (a code fence or a localized string).
|
||||
low_trust = policy.trust is Trust.UNTRUSTED or provenance in (
|
||||
Provenance.CODE_FENCE,
|
||||
Provenance.LOCALIZED,
|
||||
)
|
||||
|
||||
disposition = _base_disposition(report, max_sev, low_trust, policy, reasons)
|
||||
|
||||
# Overlay B — compound escalation (§4.6): several weaker signals escalate one
|
||||
# tier even when each alone would only WARN.
|
||||
if _is_compound(report):
|
||||
escalated = _escalate(disposition)
|
||||
if escalated is not disposition:
|
||||
reasons.append("compound: >=2 findings at MEDIUM+ -> escalated one tier")
|
||||
disposition = escalated
|
||||
|
||||
return DispositionResult(disposition, tuple(reasons), max_sev)
|
||||
|
||||
|
||||
def _base_disposition(
|
||||
report: Report,
|
||||
max_sev: Optional[Severity],
|
||||
low_trust: bool,
|
||||
policy: Policy,
|
||||
reasons: list[str],
|
||||
) -> Disposition:
|
||||
tier = "low" if low_trust else "high"
|
||||
if max_sev is None:
|
||||
disposition = Disposition.WARN
|
||||
reasons.append("clean: no findings")
|
||||
elif max_sev is Severity.HIGH:
|
||||
disposition = Disposition.FAIL_SECURE if low_trust else Disposition.WARN
|
||||
reasons.append(f"HIGH under {tier}-trust -> {disposition.value}")
|
||||
elif max_sev is Severity.MEDIUM:
|
||||
disposition = Disposition.QUARANTINE_REVIEW if low_trust else Disposition.WARN
|
||||
reasons.append(f"MEDIUM under {tier}-trust -> {disposition.value}")
|
||||
else: # LOW or INFO
|
||||
disposition = Disposition.WARN
|
||||
reasons.append(f"{max_sev.value} -> WARN")
|
||||
|
||||
# quarantine_default floor (upload preset): any finding is held for review.
|
||||
if policy.quarantine_default and report.found:
|
||||
floored = _more_severe(disposition, Disposition.QUARANTINE_REVIEW)
|
||||
if floored is not disposition:
|
||||
reasons.append("quarantine-floor: untrusted upload, any finding -> QUARANTINE_REVIEW")
|
||||
disposition = floored
|
||||
|
||||
return disposition
|
||||
|
||||
|
||||
def guard(
|
||||
scan_fn: Callable[[], Report],
|
||||
policy: Policy,
|
||||
*,
|
||||
provenance: Provenance = Provenance.PROSE,
|
||||
transform_failed: bool = False,
|
||||
) -> DispositionResult:
|
||||
"""Run ``scan_fn`` and dispose the result, failing *closed* on error.
|
||||
|
||||
A scanner that raises (a missing detector dependency, a crash on crafted
|
||||
input) must never yield an auto-persist: an un-scannable artifact is a BLOCK
|
||||
(BRIEF §4.6, fail-closed).
|
||||
"""
|
||||
try:
|
||||
report = scan_fn()
|
||||
except Exception as exc: # noqa: BLE001 — fail closed on ANY scanner error
|
||||
return DispositionResult(
|
||||
Disposition.FAIL_SECURE,
|
||||
(f"fail-closed: scanner error: {type(exc).__name__}",),
|
||||
None,
|
||||
)
|
||||
return decide(report, policy, provenance=provenance, transform_failed=transform_failed)
|
||||
|
||||
|
||||
PRESET_TRUSTED_SOURCE = Policy(trust=Trust.TRUSTED)
|
||||
"""Pinned, reputable single-author source (a changelog): security vocabulary
|
||||
WARNs, while CRITICAL findings and invisible carriers still fail secure."""
|
||||
|
||||
PRESET_USER_UPLOAD = Policy(trust=Trust.UNTRUSTED, quarantine_default=True)
|
||||
"""High-untrust user-upload / open-source inbox (the flagship consumer): any
|
||||
finding is held for QUARANTINE_REVIEW and CRITICAL hard-fails — over-blocking one
|
||||
upload is far cheaper than persisting a poisoned one (BRIEF §4.7)."""
|
||||
231
tests/test_disposition.py
Normal file
231
tests/test_disposition.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Tests for disposition — the Report -> gate-decision policy (BRIEF §4.6/§4.7)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_guard.report import Finding, Report, Severity, Source
|
||||
from llm_ingestion_guard.disposition import (
|
||||
Disposition,
|
||||
DispositionResult,
|
||||
Policy,
|
||||
Provenance,
|
||||
Trust,
|
||||
decide,
|
||||
guard,
|
||||
PRESET_TRUSTED_SOURCE,
|
||||
PRESET_USER_UPLOAD,
|
||||
)
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
def _finding(label="lexicon:override", severity=Severity.HIGH, detector="lexicon",
|
||||
source=Source.INPUT):
|
||||
return Finding(label=label, severity=severity, source=source, detector=detector)
|
||||
|
||||
|
||||
def _report(*findings):
|
||||
report = Report()
|
||||
for finding in findings:
|
||||
report.add(finding)
|
||||
return report
|
||||
|
||||
|
||||
TRUSTED = Policy(trust=Trust.TRUSTED)
|
||||
UNTRUSTED = Policy(trust=Trust.UNTRUSTED)
|
||||
|
||||
|
||||
# --- any-tier exceptions (§4.7): CRITICAL + invisible carriers -------------
|
||||
|
||||
def test_critical_fails_secure_even_in_trusted_prose():
|
||||
# trust cannot rescue a CRITICAL finding — it blocks in any tier.
|
||||
report = _report(_finding(label="lexicon:identity-redef", severity=Severity.CRITICAL))
|
||||
result = decide(report, TRUSTED, provenance=Provenance.PROSE)
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert result.max_severity is Severity.CRITICAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carrier_label,severity", [
|
||||
("sanitize:zero-width", Severity.HIGH),
|
||||
("sanitize:bidi-override", Severity.HIGH),
|
||||
("sanitize:unicode-tag", Severity.CRITICAL),
|
||||
])
|
||||
def test_invisible_carrier_fails_secure_in_any_tier(carrier_label, severity):
|
||||
# zero-width/bidi are HIGH (would only WARN in trusted prose by severity
|
||||
# alone); the carrier rule overrides and fails secure regardless of tier.
|
||||
report = _report(_finding(label=carrier_label, severity=severity, detector="sanitize"))
|
||||
result = decide(report, TRUSTED, provenance=Provenance.PROSE)
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert any("carrier" in reason for reason in result.reasons)
|
||||
|
||||
|
||||
# --- HIGH severity across the matrix --------------------------------------
|
||||
|
||||
def test_high_trusted_prose_warns():
|
||||
# HIGH in authored prose from a trusted source: legit security vocabulary
|
||||
# dominates -> WARN, not block.
|
||||
report = _report(_finding(severity=Severity.HIGH))
|
||||
assert decide(report, TRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provenance", [Provenance.CODE_FENCE, Provenance.LOCALIZED])
|
||||
def test_high_trusted_lowtrust_provenance_fails_secure(provenance):
|
||||
# the same HIGH hit inside a code fence / localized string hard-fails.
|
||||
report = _report(_finding(severity=Severity.HIGH))
|
||||
assert decide(report, TRUSTED, provenance=provenance).disposition is Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
def test_high_untrusted_fails_secure():
|
||||
report = _report(_finding(severity=Severity.HIGH))
|
||||
assert decide(report, UNTRUSTED, provenance=Provenance.PROSE).disposition is Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
# --- MEDIUM severity ------------------------------------------------------
|
||||
|
||||
def test_medium_trusted_prose_warns():
|
||||
report = _report(_finding(severity=Severity.MEDIUM, label="lexicon:config"))
|
||||
assert decide(report, TRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_medium_trusted_codefence_quarantines():
|
||||
report = _report(_finding(severity=Severity.MEDIUM, label="lexicon:config"))
|
||||
assert decide(report, TRUSTED, provenance=Provenance.CODE_FENCE).disposition is Disposition.QUARANTINE_REVIEW
|
||||
|
||||
|
||||
def test_medium_untrusted_quarantines():
|
||||
report = _report(_finding(severity=Severity.MEDIUM, label="lexicon:config"))
|
||||
assert decide(report, UNTRUSTED, provenance=Provenance.PROSE).disposition is Disposition.QUARANTINE_REVIEW
|
||||
|
||||
|
||||
# --- LOW / clean ----------------------------------------------------------
|
||||
|
||||
def test_low_trusted_warns():
|
||||
report = _report(_finding(severity=Severity.LOW, label="lexicon:soft"))
|
||||
assert decide(report, TRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_low_plain_untrusted_warns_without_quarantine_default():
|
||||
# plain untrusted (no upload preset) does not force quarantine on a LOW hit.
|
||||
report = _report(_finding(severity=Severity.LOW, label="lexicon:soft"))
|
||||
assert decide(report, UNTRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_clean_report_warns():
|
||||
assert decide(_report(), TRUSTED).disposition is Disposition.WARN
|
||||
assert decide(_report(), UNTRUSTED).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_clean_report_max_severity_none():
|
||||
assert decide(_report(), TRUSTED).max_severity is None
|
||||
|
||||
|
||||
# --- Overlay A: compound forced-fallback (§4.6) ---------------------------
|
||||
|
||||
def test_transform_failure_with_findings_fails_secure():
|
||||
# a scan hit plus a transform failure is a probable forced-fallback attack.
|
||||
report = _report(_finding(severity=Severity.MEDIUM, label="lexicon:config"))
|
||||
result = decide(report, TRUSTED, provenance=Provenance.PROSE, transform_failed=True)
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert any("forced-fallback" in reason for reason in result.reasons)
|
||||
|
||||
|
||||
def test_transform_failure_without_findings_does_not_fail_secure():
|
||||
# a transform failure alone (clean scan) is not the forced-fallback signal.
|
||||
result = decide(_report(), TRUSTED, transform_failed=True)
|
||||
assert result.disposition is Disposition.WARN
|
||||
|
||||
|
||||
# --- Overlay B: compound escalation (§4.6) --------------------------------
|
||||
|
||||
def test_two_medium_findings_escalate_in_trusted_prose():
|
||||
# each MEDIUM alone -> WARN in trusted prose; two together escalate one tier.
|
||||
report = _report(
|
||||
_finding(severity=Severity.MEDIUM, label="lexicon:config"),
|
||||
_finding(severity=Severity.MEDIUM, label="entropy:base64", detector="entropy"),
|
||||
)
|
||||
result = decide(report, TRUSTED, provenance=Provenance.PROSE)
|
||||
assert result.disposition is Disposition.QUARANTINE_REVIEW
|
||||
assert any("compound" in reason for reason in result.reasons)
|
||||
|
||||
|
||||
def test_single_medium_does_not_escalate():
|
||||
report = _report(_finding(severity=Severity.MEDIUM, label="lexicon:config"))
|
||||
assert decide(report, TRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_two_low_findings_do_not_escalate():
|
||||
# LOW findings are trivial signals; they do not compound into escalation.
|
||||
report = _report(
|
||||
_finding(severity=Severity.LOW, label="a", detector="lexicon"),
|
||||
_finding(severity=Severity.LOW, label="b", detector="entropy"),
|
||||
)
|
||||
assert decide(report, TRUSTED, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
# --- Overlay C: fail-closed guard -----------------------------------------
|
||||
|
||||
def test_guard_fails_secure_on_scanner_exception():
|
||||
def boom():
|
||||
raise RuntimeError("detector unavailable")
|
||||
result = guard(boom, TRUSTED)
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert any("fail-closed" in reason for reason in result.reasons)
|
||||
assert result.max_severity is None
|
||||
|
||||
|
||||
def test_guard_passes_through_clean_scan():
|
||||
assert guard(lambda: _report(), TRUSTED).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_guard_disposes_findings_like_decide():
|
||||
report = _report(_finding(severity=Severity.CRITICAL, label="lexicon:override"))
|
||||
assert guard(lambda: report, TRUSTED).disposition is Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
# --- Presets --------------------------------------------------------------
|
||||
|
||||
def test_user_upload_preset_quarantines_any_finding():
|
||||
# a single LOW finding that would WARN under a plain policy -> QUARANTINE here.
|
||||
report = _report(_finding(severity=Severity.LOW, label="lexicon:soft"))
|
||||
assert decide(report, PRESET_USER_UPLOAD).disposition is Disposition.QUARANTINE_REVIEW
|
||||
|
||||
|
||||
def test_user_upload_preset_hard_fails_on_critical():
|
||||
report = _report(_finding(severity=Severity.CRITICAL, label="lexicon:override"))
|
||||
assert decide(report, PRESET_USER_UPLOAD).disposition is Disposition.FAIL_SECURE
|
||||
|
||||
|
||||
def test_user_upload_preset_clean_report_warns():
|
||||
# no finding -> no quarantine floor.
|
||||
assert decide(_report(), PRESET_USER_UPLOAD).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_trusted_source_preset_warns_on_high_prose():
|
||||
report = _report(_finding(severity=Severity.HIGH))
|
||||
assert decide(report, PRESET_TRUSTED_SOURCE, provenance=Provenance.PROSE).disposition is Disposition.WARN
|
||||
|
||||
|
||||
def test_presets_are_policies():
|
||||
assert isinstance(PRESET_TRUSTED_SOURCE, Policy)
|
||||
assert PRESET_TRUSTED_SOURCE.trust is Trust.TRUSTED
|
||||
assert PRESET_USER_UPLOAD.trust is Trust.UNTRUSTED
|
||||
assert PRESET_USER_UPLOAD.quarantine_default is True
|
||||
|
||||
|
||||
# --- DispositionResult shape ----------------------------------------------
|
||||
|
||||
def test_result_is_dataclass_with_auditable_reasons():
|
||||
report = _report(_finding(severity=Severity.CRITICAL, label="lexicon:override"))
|
||||
result = decide(report, TRUSTED)
|
||||
assert isinstance(result, DispositionResult)
|
||||
assert result.reasons # non-empty, auditable trail
|
||||
|
||||
|
||||
def test_floor_and_escalation_compose_to_fail_secure():
|
||||
# upload preset + two MEDIUM untrusted: base QUARANTINE (low-trust) then
|
||||
# compound escalation -> FAIL_SECURE.
|
||||
report = _report(
|
||||
_finding(severity=Severity.MEDIUM, label="lexicon:config"),
|
||||
_finding(severity=Severity.MEDIUM, label="entropy:base64", detector="entropy"),
|
||||
)
|
||||
assert decide(report, PRESET_USER_UPLOAD).disposition is Disposition.FAIL_SECURE
|
||||
Loading…
Add table
Add a link
Reference in a new issue