1
0
Fork 0
llm-ingestion-pipeline-secu.../src/llm_ingestion_guard/disposition.py
Kjell Tore Guttormsen de097110d2 feat(disposition): separate the assessment axis from the action
`decide` returned a `Disposition` — WARN / QUARANTINE_REVIEW / FAIL_SECURE —
which names an ACTION. But BRIEF design principle 4 says the library reports
and the pipeline decides, and disposition.py admitted the gap in its own
docstring: "It imposes no blocking of its own." So we returned an action we
cannot enforce, having discarded the judgement that produced it. A consumer
wanting different behaviour had to reinterpret the action itself — which is
why a consumer ends up pinning our GRADING: the action was all they got.

`Risk` (NONE/LOW/ELEVATED/SEVERE) now carries that judgement, and
`Policy.action_map` lets a caller map it to their own action. Both overlays
move the assessment rather than the action, so a custom map cannot silently
drop the compound escalation or the quarantine floor. `guard`'s fail-closed
path pins both axes and deliberately bypasses the map: downgrading SEVERE
means "I accept this class of finding", never "I accept a crashed scanner".

`DispositionResult.assessment` is required with no default. `Risk.NONE` is the
natural-looking default and the wrong one — a site that forgot the field would
report clean, and the axis would fail open.

MEASURED ADDITIVE, not assumed:
  - 703 -> 715 tests, no existing test changed
  - coverage matrix 128/128 recall, 6/6 documented gaps still hold
  - the PRESET_USER_UPLOAD grading table locked in 0.3.1 re-measured row by
    row: ordinary link/image/autolink/refdef -> warn on BOTH doors, unchanged

Both locked consumer promises in docs/PLAN-v1.md were checked against that
measurement and neither fires: the grading is untouched (linkedin-studio), and
the relative-target asymmetry is untouched (llm-ingestion-okf).

Scope held to disposition, per PLAN-v1.md:380. Version stays 0.4.0; the 0.5.0
bump lands in its release commit with all five version surfaces at once —
that is the fix for the defect where the v0.4.0 tag carried a 0.3.4 README.

Records limitation 32: `Severity` still carries disposition intent on the
DETECTION side, which this change does not address and cannot without moving
the grading.
2026-08-10 20:55:46 +02:00

333 lines
14 KiB
Python

"""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, Mapping, Optional
from .calibration import DEFAULT_ACTION_MAP as _DEFAULT_ACTION_MAP_VALUES
from .calibration import DISPOSITION_RANK, RISK_RANK
from .report import Report, Severity, severity_rank
class Risk(str, Enum):
"""How dangerous the artifact is *given its source context* — the assessment.
The other half of the 0.5.0 axis separation. :class:`Disposition` names an
**action**; ``Risk`` names the **judgement that action was derived from**.
Through 0.4.0 only the action was returned, which had two costs: a consumer
who wanted different behaviour had to reinterpret an action whose reasoning
was already gone, and ``NONE`` vs ``LOW`` — a clean document versus one
carrying only low-severity findings — were indistinguishable, since both
render as ``WARN``.
Risk is **trust-aware**, exactly as BRIEF §4.7 describes the domain: the
same finding genuinely *is* a different judgement in authored prose than in
a code fence, not merely a different action taken on one shared judgement.
"""
NONE = "none" # no findings at all
LOW = "low" # findings present, none dispositive in this context
ELEVATED = "elevated" # suspicious enough to hold for a human
SEVERE = "severe" # treat as a real payload
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.
``action_map`` overrides how an assessed :class:`Risk` becomes a
:class:`Disposition`. ``None`` — the default — means
:data:`DEFAULT_ACTION_MAP`, which reproduces every disposition 0.4.0
rendered. It is deliberately ``None`` rather than the map itself so an
untouched ``Policy`` stays hashable exactly as before.
This is the seam a consumer needs: wanting *hold for review* where we
render *fail secure* is now a policy statement, not a reason to pin our
grading.
"""
trust: Trust
quarantine_default: bool = False # any finding -> at least QUARANTINE_REVIEW
action_map: Optional[Mapping[Risk, Disposition]] = None
@dataclass(frozen=True)
class DispositionResult:
"""The decision plus an auditable trail of which rules fired.
``assessment`` is the risk the rules actually established; ``disposition``
is that risk mapped through the policy's action map. Consumers that gate on
the assessment are insulated from a later recalibration of the mapping.
"""
disposition: Disposition
reasons: tuple[str, ...]
max_severity: Optional[Severity]
assessment: Risk
# Deliberately *required*, with no default. ``Risk.NONE`` would be the
# natural-looking default and is precisely the wrong one: a construction
# site that forgot the field would report a clean assessment, so the axis
# would fail open. Fail-loud beats fail-silent for a gate (BRIEF §4.7).
# Invisible carriers have no legitimate place in a reference file: they block in
# any tier regardless of trust or provenance (BRIEF §4.7). Both the input-side
# (``sanitize:*``, which strips) and the output-side presence labels are listed
# so the invariant holds on the persist gate too — model output is never
# sanitized, so its carrier signal arrives via ``output``/``lexicon`` instead.
_CARRIER_LABELS = frozenset({
"sanitize:zero-width",
"sanitize:bidi-override",
"sanitize:unicode-tag",
"output:zero-width-present",
"output:bidi-present",
"lexicon:unicode-tags-present",
})
# Enum-keyed ranks rebuilt from calibration's value-keyed source of truth
# (calibration is a leaf module and cannot import these enums without a cycle).
# Higher = more severe.
_DISPOSITION_RANK = {d: DISPOSITION_RANK[d.value] for d in Disposition}
_RISK_RANK = {r: RISK_RANK[r.value] for r in Risk}
DEFAULT_ACTION_MAP: Mapping[Risk, Disposition] = {
Risk(risk_value): Disposition(disposition_value)
for risk_value, disposition_value in _DEFAULT_ACTION_MAP_VALUES.items()
}
"""The default :class:`Risk` -> :class:`Disposition` mapping.
Reproduces every disposition 0.4.0 rendered, so the axis separation is additive:
a caller that never reads ``assessment`` sees no behavioural change.
"""
def _more_severe(a: Risk, b: Risk) -> Risk:
return a if _RISK_RANK[a] >= _RISK_RANK[b] else b
def _escalate(risk: Risk) -> Risk:
"""Escalate one tier on the assessment axis.
``NONE`` is unreachable here — the only caller is the compound overlay,
which needs two MEDIUM+ findings and so implies at least ``LOW`` — but it
escalates rather than being a no-op, so the function is total.
"""
if risk is Risk.SEVERE:
return Risk.SEVERE
return Risk(next(
r for r in Risk if _RISK_RANK[r] == _RISK_RANK[risk] + 1
))
def _action(risk: Risk, policy: Policy) -> Disposition:
"""Map an assessed ``risk`` to an action under ``policy``.
An action map that omits a risk level falls back to the default rather than
raising: a partial override is a likely way to use this, and a ``KeyError``
from inside the gate would be turned into a fail-closed by :func:`guard`
anyway — silently, and with a useless reason.
"""
if policy.action_map is not None and risk in policy.action_map:
return policy.action_map[risk]
return DEFAULT_ACTION_MAP[risk]
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()
def result(risk: Risk) -> DispositionResult:
return DispositionResult(_action(risk, policy), tuple(reasons), max_sev, risk)
# 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 result(Risk.SEVERE)
# 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 result(Risk.SEVERE)
if max_sev is Severity.CRITICAL:
reasons.append("any-tier: CRITICAL finding")
return result(Risk.SEVERE)
# 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,
)
risk = _base_risk(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. Escalating the *assessment*
# rather than the action is what keeps the overlay from being silently lost
# under a custom action map.
if _is_compound(report):
escalated = _escalate(risk)
if escalated is not risk:
reasons.append("compound: >=2 findings at MEDIUM+ -> escalated one tier")
risk = escalated
return result(risk)
def _base_risk(
report: Report,
max_sev: Optional[Severity],
low_trust: bool,
policy: Policy,
reasons: list[str],
) -> Risk:
"""Assess ``report`` before the overlays, on the risk axis.
The reason strings still name the *disposition* each branch yields, because
they are an audit trail consumers already read; the axis separation must not
silently reword it. They are rendered through :func:`_action` so a policy
that remaps an action gets a trail that matches what it actually did.
"""
tier = "low" if low_trust else "high"
if max_sev is None:
risk = Risk.NONE
reasons.append("clean: no findings")
elif max_sev is Severity.HIGH:
risk = Risk.SEVERE if low_trust else Risk.LOW
reasons.append(f"HIGH under {tier}-trust -> {_action(risk, policy).value}")
elif max_sev is Severity.MEDIUM:
risk = Risk.ELEVATED if low_trust else Risk.LOW
reasons.append(f"MEDIUM under {tier}-trust -> {_action(risk, policy).value}")
else: # LOW or INFO
risk = Risk.LOW
reasons.append(f"{max_sev.value} -> WARN")
# quarantine_default floor: a finding at MEDIUM+ is held for review.
#
# Through 0.3.0 this floor fired on *any* finding, on the premise that a
# finding is the exception. Adding the active-content detector broke that
# premise — every ordinary markdown link became a finding — and the floor
# then quarantined documents whose only sin was linking somewhere. Raising it
# to MEDIUM+ restores the intent (hold what is actually suspicious) and is a
# no-op for every detector that existed before 0.3.0: none of them emit LOW.
if policy.quarantine_default and max_sev is not None and (
severity_rank(max_sev) >= severity_rank(Severity.MEDIUM)
):
floored = _more_severe(risk, Risk.ELEVATED)
if floored is not risk:
reasons.append("quarantine-floor: MEDIUM+ finding -> QUARANTINE_REVIEW")
risk = floored
return risk
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) — or a disposition that raises (a malformed report) — must never
yield an auto-persist: an un-scannable / un-disposable artifact is a BLOCK
(BRIEF §4.6, fail-closed). ``decide`` runs inside the guarded block so the
fail-closed guarantee is total.
"""
try:
report = scan_fn()
return decide(report, policy, provenance=provenance, transform_failed=transform_failed)
except Exception as exc: # noqa: BLE001 — fail closed on ANY scan/dispose error
# Both axes are pinned to their most severe value, and deliberately NOT
# routed through the policy's action map: an un-scannable artifact is
# not a risk judgement a caller gets to remap. A policy that downgrades
# SEVERE means "I accept this class of finding", never "I accept a
# scanner that crashed on crafted input" (BRIEF §4.6, fail closed).
return DispositionResult(
Disposition.FAIL_SECURE,
(f"fail-closed: scan/dispose error: {type(exc).__name__}",),
None,
Risk.SEVERE,
)
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)."""