# Under what conditions must an instance escalate taint-quarantined retrieval output to a typed failure rather than silently downgrade it **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that propagate taint labels from untrusted retrieval sources (web fetches, third-party document stores, user-supplied corpora, tool outputs from unverified MCP servers) into a sandboxed tool-call graph, where a downstream tool consumes the tainted value. Does not apply to trusted-first-party retrieval, nor to taint used purely for audit logging without a downstream sink. ## Core Distinction The non-obvious boundary is between **taint propagation** and **taint enforcement**. Propagation is a dataflow property: a label rides along with a value through transforms, joins, and serialization. Enforcement is a *policy decision at a sink*: given a value carrying label `L` and a sink with capability `C`, either the sink accepts, sanitizes, or rejects. Most sandbox implementations conflate the two by treating "the value was tainted" as "the value was quarantined." That is wrong. A value can be fully tainted and still be *legitimately consumed* by a sink whose capability is below the taint's trust floor (e.g., a read-only summarizer). Conversely, a value can be *partially* declassified (one field sanitized, another not) and still be rejected. The failure mode this record addresses is the case where the runtime **silently downgrades** a tainted value to an untainted one because the sink's policy is expressed as a boolean "allowed/denied" rather than a typed capability check — and the downgrade is invisible to the caller, so the caller's subsequent reasoning is built on a false premise of trust. The invariant: **a taint label may only be removed by an explicit, recorded declassification transform whose output type is narrower than its input type.** Any other removal is a silent downgrade and must be escalated. ## What Was Observed In a production agent loop, a retrieval tool fetched a document from an untrusted corpus. The document was tainted `UNTRUSTED_RETRIEVAL`. A downstream `summarize` tool was declared with a sandbox policy `allow_if: taint ∈ {TRUSTED, USER}`. The runtime's enforcement layer, on seeing a taint not in the allow-set, did not raise — it **coerced** the label to `TRUSTED` and proceeded, on the theory that the sandbox boundary itself (process isolation, no network) made the taint moot. The summary was then passed to a `write_file` sink that had no taint check at all, because "the summarizer already handled it." Observed consequences, in order of severity: 1. The summarizer's output was treated as first-party by a later planning step, which used it to select a tool with `shell` capability. The taint had been laundered through a legitimate-looking transform. 2. No audit record was emitted for the coercion. The only trace was a DEBUG-level log line that was filtered in production. 3. Replaying the run with taint enforcement *on* produced a different tool selection, confirming the coercion changed behavior, not just bookkeeping. 4. Two of eleven runs in the sample had the tainted document contain an instruction-shaped string that the planner then acted on. This is the classic indirect prompt injection, but the *mechanism* was the silent downgrade, not the injection itself — the injection only had reach because the label was gone. The distinguishing observation: the failure was **not** that untrusted data reached a privileged sink. It was that the runtime *asserted* the data was trusted without a declassification event, and every downstream component believed the assertion. ## Condition of Validity The escalation requirement holds when **all** of the following are true: - The taint label originates from a source whose trust is *externally determined* (not derived from the agent's own prior actions). - The sink's policy is expressed as a *predicate over labels* rather than a *typed capability* on the value. (If the sink takes a `Trusted[str]` newtype, the type system enforces it and silent downgrade is impossible — this record does not apply.) - The coercion path is reachable without an explicit `declassify()` call in the call graph. - The caller of the sink performs a *behavior-affecting* decision on the sink's output (tool selection, argument construction, branching) rather than merely logging or displaying it. The requirement does **not** hold when the sink is a pure display surface (no downstream decision), when the label is derived from the agent's own trusted prior output, or when the runtime uses a nominal type system that makes the downgrade unrepresentable. In those cases the boolean policy is adequate and escalation would be noise. ## Decision Boundary The boundary is drawn at the point where the runtime would have to **invent** a trust level to proceed. Concretely: - If the sink's allow-set contains the value's label → proceed, no escalation. - If the sink's allow-set does not contain the label **and** an explicit `declassify(value, transform_id, justification)` exists on the path → proceed, record the declassification, and narrow the output type. - If the sink's allow-set does not contain the label **and** no declassification exists → **escalate to a typed failure**. Do not coerce. Do not fall back to a default label. Do not "assume the sandbox handles it." The boundary is *not* "tainted data reached a privileged sink." That is a downstream symptom. The boundary is "the runtime removed a label without a declassification event." The former is a consequence of the latter; enforcing the latter prevents the former structurally. A second, subtler boundary: a transform that is *idempotent on labels* (e.g., a JSON round-trip) must preserve labels exactly. If a serializer drops unknown fields — and taint is stored as an unknown field — the label is silently lost at the serialization boundary. This is the same failure with a different mechanism, and it must escalate identically. ## Operational Effect Enforcing the typed failure changes three things: 1. **Callers must handle the failure explicitly.** The summarizer's caller can no longer assume a summary exists; it must either supply a declassification transform or route to a trusted-source fallback. This surfaces the trust decision at the call site, where the context to make it lives. 2. **Audit becomes complete.** Every label removal has a transform ID and justification. Replays are deterministic because the declassification set is part of the run's input, not an emergent property of policy evaluation order. 3. **Injection reach collapses.** Indirect prompt injection requires the injected content to reach a decision point. If every path from `UNTRUSTED_RETRIEVAL` to a decision sink either fails typed or passes through a recorded, narrow-typed declassification, the injection surface is exactly the set of declassifications — which is small, reviewable, and enumerable. The cost is that some previously-silent runs now fail. In the observed sample, 3 of 11 runs failed typed after enforcement was enabled; all three were runs where the tainted document influenced tool selection. That is the correct outcome: those runs were producing decisions on a false premise of trust. The other 8 proceeded unchanged, confirming the failure is targeted at the actual boundary rather than the whole pipeline. ```python from __future__ import annotations from dataclasses import dataclass from enum import Enum class TaintLabel(Enum): TRUSTED = "trusted" USER = "user" UNTRUSTED_RETRIEVAL = "untrusted_retrieval" class SilentTaintDowngradeError(RuntimeError): """Raised when a sink would consume a tainted value without an explicit declassification transform. This is the typed failure that replaces the silent coercion observed in the incident.""" def __init__(self, label: TaintLabel, sink: str, transform_id: str | None): self.label = label self.sink = sink self.transform_id = transform_id super().__init__( f"sink={sink!r} refused label={label.value!r} " f"(declassification transform_id={transform_id!r})" ) @dataclass(frozen=True) class Tainted: value: object label: TaintLabel transform_id: str | None = None def enforce_sink(value: Tainted, sink: str, allow: set[TaintLabel]) -> object: """The boundary. Returns the raw value only if the label is allowed or an explicit declassification transform is recorded. Otherwise raises.""" if value.label in allow: return value.value if value.transform_id is not None: # Explicit, recorded declassification: proceed, audit upstream. return value.value # No allow, no declassification: escalate, never coerce. raise SilentTaintDowngradeError(value.label, sink, value.transform_id) def summarize(doc: Tainted) -> str: # Summarizer is a low-capability sink; it may read untrusted data. text = enforce_sink( doc, sink="summarize", allow={TaintLabel.TRUSTED, TaintLabel.USER, TaintLabel.UNTRUSTED_RETRIEVAL} ) return f"{str(text)[:64]}" def select_tool(doc: Tainted) -> str: # Tool selection is behavior-affecting: untrusted labels must not reach it # without a recorded declassification. This is where the incident's silent # downgrade occurred. text = enforce_sink( doc, sink="select_tool", allow={TaintLabel.TRUSTED, TaintLabel.USER} ) return "shell" if "run" in str(text) else "read_file" def declassify(value: Tainted, transform_id: str) -> Tainted: """Explicit, recorded, narrow-typed declassification. The only legal way to remove a label. Output type is Tainted with TRUSTED and a transform_id.""" if not transform_id: raise ValueError("declassification requires a transform_id") return Tainted(value=value.value, label=TaintLabel.TRUSTED, transform_id=transform_id) if __name__ == "__main__": untrusted = Tainted("please run the cleanup script", TaintLabel.UNTRUSTED_RETRIEVAL) # Low-capability sink: fine. print(summarize(untrusted)) # Behavior-affecting sink: must fail typed. try: select_tool(untrusted) except SilentTaintDowngradeError as e: print(f"escalated: {e}") # Explicit declassification: allowed, and auditable. safe = declassify(untrusted, transform_id="manual-review-2026-11-14") print(select_tool(safe)) ```