# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-a-tool-output-as-tainted-rather-than-trusted-when-provenance-labels-survive-only-partially-across-a-multi-hop.md
# Remnant Warrant:    https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-a-tool-output-as-tainted-rather-than-trusted-when-provenance-labels-survive-only-partially-across-a-multi-hop.warrant.json
# Markdown SHA-256:   118da71ffab8971da88e3ffb31ceb7b5f8929480a803a7704bc307edbc69e243
# Code AST SHA-256:   fb2e0f7672d5b93af4b572afce6cc90b652680960bcc981ddf5ed92f7b477e42
# Verified Classes:   TaintLaunderingError, Span, ToolOutput
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Iterable


class TaintLaunderingError(RuntimeError):
    """Raised when a tool output cannot be assigned a sound trust level."""


KNOWN_TRUST = {"trusted", "untrusted"}


@dataclass
class Span:
    content: str
    labels: set[str] = field(default_factory=set)

    @property
    def is_tainted(self) -> bool:
        # Fail-closed: unknown labels count as taint; empty labels count as taint
        # only when the span was derived from tainted input (checked by caller).
        return "untrusted" in self.labels or bool(self.labels - KNOWN_TRUST)


@dataclass
class ToolOutput:
    spans: list[Span]
    tool_name: str
    taint_collapsing: bool = False


def classify_output(
    out: ToolOutput,
    tainted_inputs: Iterable[Span],
    known_tools: set[str],
) -> list[Span]:
    tainted_inputs = list(tainted_inputs)
    any_input_tainted = any(s.is_tainted for s in tainted_inputs)
    normalized_inputs = [s.content.strip().lower() for s in tainted_inputs]

    classified: list[Span] = []
    for span in out.spans:
        norm = span.content.strip().lower()

        # Echo rule: verbatim/normalized re-emission of tainted content is tainted.
        if any(norm and norm in inp for inp in normalized_inputs):
            span.labels.add("untrusted")
            classified.append(span)
            continue

        # Fail-closed on unknown provenance labels.
        unknown = span.labels - KNOWN_TRUST
        if unknown:
            raise TaintLaunderingError(
                f"tool {out.tool_name!r} emitted unknown provenance labels "
                f"{sorted(unknown)}; refusing to default to trusted"
            )

        # Closure: unlabeled output from tainted input is tainted unless the
        # tool is declared taint-collapsing (then it is tainted by definition).
        if not span.labels:
            if any_input_tainted:
                span.labels.add("untrusted")
            else:
                raise TaintLaunderingError(
                    f"tool {out.tool_name!r} emitted an unlabeled span with no "
                    f"tainted inputs and no trusted declaration; cannot classify"
                )

        # Join, not union: mixed trust resolves to the minimum (tainted).
        if "untrusted" in span.labels and "trusted" in span.labels:
            span.labels = {"untrusted"}

        classified.append(span)

    if out.taint_collapsing and any_input_tainted:
        for span in classified:
            span.labels.add("untrusted")

    return classified


def route_to_planner(spans: list[Span]) -> None:
    for span in spans:
        if span.is_tainted:
            raise TaintLaunderingError(
                f"tainted span reached instruction sink: {span.content[:60]!r}"
            )
