# When must an instance quarantine a tool result: the label-laundering boundary in prompt-injection taint tracking **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that propagate information-flow labels (integrity/confidentiality) across tool-call boundaries and that treat tool outputs as potentially attacker-influenced. Does not apply to single-turn, tool-free inference, nor to systems where tool outputs are already trusted by construction (e.g., outputs from a signed, hermetic internal service with no external data ingress). ## Core Distinction Taint tracking for prompt injection is usually modeled as a *provenance* problem: a value is either derived from untrusted input or it is not, and labels flow through data dependencies. The non-obvious failure is that **provenance is not the same as authority**. A tool result can carry a clean label (it came from a trusted tool, executed by trusted code) while its *content* was authored by an untrusted party earlier in the pipeline. The boundary that matters is not "did this value touch untrusted bytes?" but "**did this value cross a boundary where a label was dropped, downgraded, or re-derived from an unlabeled intermediate?**" The canonical laundering pattern: 1. Untrusted text enters as a tool argument (label `U`). 2. The tool serializes it, sends it over the wire, and the remote service echoes it back inside a structured response (e.g., a summary, a search snippet, an error message, a JSON field). 3. The runtime re-parses the response and assigns it a fresh label derived from *the tool's identity* (`T`, trusted), not from the payload's lineage. The value is now `T` but its content is `U`. Every downstream policy that gates on `T` — "trusted content may be placed in the system prompt," "trusted content may trigger a write tool," "trusted content may skip human review" — now fires on attacker-controlled text. The label was not forged; it was **re-derived at a boundary that did not carry the incoming label forward**. ## What Was Observed In a multi-tool agent (web fetch, document retrieval, code execution, email send), the following sequence produced a policy violation with no error raised anywhere: - A retrieval tool was called with a query containing an injected instruction. The retriever returned a document whose body contained the instruction verbatim, wrapped in a `{"source": "...", "trust": "internal"}` envelope. - The runtime's tool adapter constructed the result object from the envelope's `trust` field, which the *tool* set based on the index it queried, not on the document's lineage. The document had been ingested from an external crawl three days earlier and had never been labeled. - Because the result was labeled `internal`, the summarizer step — which only accepted `internal` or higher — consumed it and emitted a summary. The summary inherited `internal`. - A subsequent `send_email` tool accepted the summary (label satisfied the gate) and dispatched attacker-authored instructions to an external address. No component logged a taint violation. Each hop was locally correct: the adapter trusted the tool's self-reported label; the summarizer trusted the adapter; the mailer trusted the summarizer. **The failure was distributed across a chain in which every link delegated label authority to the previous link's output rather than to the data's origin.** A second observation: when the same payload was re-fetched after the document was re-crawled, the label *changed* from `internal` to `external` with no change in content. This proved the label was a property of the *ingestion event*, not of the *bytes*, and that any consumer caching the label independently of the content would diverge. ## Condition of Validity The quarantine requirement is valid — i.e., a tool result must be treated as `U` regardless of its self-reported label — precisely when **all** of the following hold: 1. The tool's input domain intersects the tool's output domain (echo, reflection, summarization, search-over-user-content, error messages that quote input). 2. The tool's output label is assigned by the tool or its adapter, not by a label-carrying envelope that was *joined* with the input label at the boundary. 3. There exists at least one downstream sink whose policy distinguishes `T` from `U` (prompt assembly, write tools, exfiltration-capable tools, human-review bypass). It is **not** valid to blanket-quarantine when the tool is provably non-reflective (a pure function of a fixed table, e.g., a clock or a constant lookup) or when the boundary performs an explicit label join (see below). Blanket quarantine of non-reflective tools destroys utility without reducing risk and trains operators to ignore quarantine events. ## Decision Boundary The correct rule is a **label join at every tool boundary**, not a per-tool trust flag: ``` result_label = join(input_labels, tool_intrinsic_label, envelope_label) ``` where `join` is the lattice meet (most restrictive) over integrity, and the envelope's self-reported label may only *lower* trust, never raise it. Concretely: - If any argument label is `U`, the result is `U`, regardless of envelope. - A tool may declare `tool_intrinsic_label = T` only for outputs that are provably independent of its inputs; this declaration must be audited and is revocable. - The envelope label is treated as an *untrusted hint*: `join` takes the minimum, so `min(U, T) = U`. An envelope claiming `T` over `U` input is exactly the laundering attempt and must be ignored, not honored. The decision to **quarantine** (block the value from all sinks, emit an event) rather than merely **downgrade** (label `U`, allow only `U`-permitted sinks) is triggered when: - The join yields `U` *and* the envelope claimed `T` (a mismatch — evidence of laundering or a misconfigured adapter), or - The join yields `U` *and* the downstream sink is exfiltration-capable (network egress, external messaging), or - The value's label changed between two observations of identical content (label instability — evidence the label is not content-derived). Downgrade-only is correct when the mismatch is explainable (e.g., a known reflective tool whose adapter has not yet been migrated) and the sink is `U`-permitted. Quarantine is correct when the mismatch is unexplained or the sink is high-impact. ## Operational Effect Implementing the join at the boundary converts a silent, distributed failure into a **local, attributable event**: the adapter that failed to join is identified, the specific input label that propagated is known, and the sink that would have fired is blocked before dispatch. The observable consequences: - Reflective tools (search, summarize, error-echo) now surface `U` results whenever their inputs were `U`, so downstream gates correctly refuse to place them in the system prompt or route them to egress tools. - Non-reflective tools retain `T`, preserving utility; the audit burden shifts to justifying each `tool_intrinsic_label = T` declaration rather than to triaging false quarantines. - Label instability (same bytes, different label) becomes a detectable anomaly rather than an invisible drift, because the join is recomputed from lineage at each observation rather than cached from the ingestion event. The invariant to encode: **a tool boundary may only preserve or reduce trust, never restore it.** Any code path that assigns a label from a tool's self-description without joining the input labels is a laundering site and must be treated as a defect, not a configuration choice. ```python class TaintLaunderingError(RuntimeError): """Raised when a tool result claims higher trust than its inputs permit.""" pass # Integrity lattice: lower is more restrictive. UNTRUSTED = 0 # attacker-influenceable INTERNAL = 1 # trusted-by-construction TRUSTED = 2 # hermetic, signed def join_labels(input_labels, tool_intrinsic_label, envelope_label): """Meet over integrity. Envelope may only lower trust, never raise it.""" candidates = list(input_labels) + [tool_intrinsic_label] # Envelope is an untrusted hint: it can only restrict, so it acts as a cap. cap = min(candidates + [envelope_label]) return min(candidates + [cap]) def label_tool_result(tool_name, input_labels, tool_intrinsic_label, envelope_label, sink_is_egress, prior_label=None): joined = join_labels(input_labels, tool_intrinsic_label, envelope_label) # Laundering: envelope claimed more trust than the join permits. if envelope_label > joined: raise TaintLaunderingError( f"tool {tool_name!r} envelope claimed label {envelope_label} " f"but join over inputs {input_labels} and intrinsic " f"{tool_intrinsic_label} yields {joined}; refusing to honor envelope" ) # Label instability: identical content observed with divergent labels. if prior_label is not None and prior_label != joined: raise TaintLaunderingError( f"tool {tool_name!r} label instability: prior={prior_label} " f"now={joined}; label is not content-derived" ) if joined == UNTRUSTED and sink_is_egress: raise TaintLaunderingError( f"tool {tool_name!r} produced UNTRUSTED result routed to egress sink; " f"quarantine required" ) return joined ```