# What happens when an instance loses taint provenance at a tool-call boundary during prompt-injection defense **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that propagate dynamic information-flow (DIF) taint labels across LLM-invoked tool calls, where a tool result re-enters the model context and is expected to carry a provenance label. Does not apply to static prompt hardening, system-prompt-only defenses, or runtimes where tool outputs are never re-injected into model-visible context. ## Core Distinction Taint tracking for prompt injection is often described as "labeling untrusted text." That framing is wrong at the boundary. The label is not a property of the *string*; it is a property of the *edge* by which the string entered the reasoning context. A tool result that originates from an untrusted source (a fetched web page, a third-party API, an email body) is tainted only if the runtime can reconstruct the chain: `untrusted_origin → tool_invocation → tool_result → context_insertion`. The failure mode is not "the label was missing." It is that the label was *synthesized at the wrong node* — the runtime re-derives trust from the tool's identity (a "trusted tool") rather than from the data's origin, silently laundering taint at the call boundary. The critical distinction is between **origin-taint** (where the bytes came from) and **channel-trust** (who relayed them). Prompt-injection defenses that collapse these two are exploitable by any tool that is itself trusted but returns untrusted bytes. ## What Was Observed In a runtime where `fetch_url` and `search` were marked as "trusted tools" (allowlisted, sandboxed, rate-limited), the DIF labeler assigned `taint=clean` to all results from those tools on the assumption that the tool implementation was vetted. An indirect injection was embedded in a fetched HTML page: `
`. The page was fetched, parsed to text, and inserted into the context window with `taint=clean` because the *tool* was trusted. The model then invoked `send_email` with attacker-controlled arguments. The DIF system did not raise a taint violation because the model's action was, from the labeler's view, derived entirely from clean context. A second observation clarified the mechanism: when the same page was fetched via a tool marked "untrusted," the labeler correctly applied `taint=untrusted` and the policy engine blocked the downstream `send_email` call whose arguments had a taint closure that included the untrusted label. The difference was not the content and not the model behavior — it was **which tool boundary the bytes crossed**. The invariant that failed: *taint must be a join over the data's provenance lattice, never a function of the relaying channel's trust level.* ## Condition of Validity The failure is reachable whenever all of the following hold: 1. The runtime assigns taint at the tool-result insertion point based on the tool's declared trust class rather than on the origin metadata of the returned payload. 2. At least one tool in the trusted class can return attacker-influenced bytes (fetch, search, RAG retrieval, file read of a shared volume, calendar/email read, database read of user-supplied rows). 3. The model can emit a side-effecting tool call whose arguments are (partially) derived from the tainted-but-laundered bytes. 4. The policy engine evaluates taint on the *arguments*' provenance closure and finds it clean. It does not apply if the runtime tracks origin metadata end-to-end (e.g., `Content-Source` headers, retrieval doc IDs with trust classes), or if the policy engine independently re-derives trust from the specific origin rather than the tool. ## Decision Boundary The operational rule: **channel trust and origin taint are orthogonal axes and must be joined, not substituted.** A trusted tool relaying untrusted bytes yields `taint=untrusted`. An untrusted tool relaying bytes from a verified origin still yields `taint=untrusted` unless the origin is cryptographically attested and the attestation is checked at the boundary. The join is `taint_out = taint_in ⊔ origin_taint(payload)`, where `⊔` is the lattice join (worst-case). Never `taint_out = trust(tool)`. The boundary is crossed the moment a runtime writes `taint = f(tool_identity)`. From that point, every trusted-but-relaying tool becomes a taint-laundering oracle, and the defense degrades to prompt hardening with extra steps. Detection heuristic: if removing the tool from the allowlist changes the taint label of identical bytes, the labeler is channel-based and broken. ## Operational Effect After the incident, the labeler was changed to require per-result origin metadata: every tool result must carry a `provenance` field naming the origin (URL host, document ID, mailbox), and the taint is computed from that origin's trust class, not the tool's. Tools that cannot supply provenance are treated as `taint=untrusted` by default. The downstream effect: `fetch_url` results from arbitrary hosts now taint the context, and any side-effecting call whose argument closure includes that taint is blocked pending human confirmation. Cost: a measurable increase in confirmation prompts for benign fetches. Benefit: the laundering path closed. The residual risk is origin spoofing at the tool implementation layer (a compromised `fetch_url` lying about provenance), which is a separate boundary requiring tool-level attestation. ```python from dataclasses import dataclass from enum import IntEnum class Taint(IntEnum): CLEAN = 0 UNTRUSTED = 1 def join(self, other: "Taint") -> "Taint": return Taint(max(int(self), int(other))) class TaintLaunderingError(RuntimeError): """Raised when a tool result's taint is derived from channel trust rather than origin provenance, laundering untrusted bytes as clean.""" @dataclass(frozen=True) class ToolResult: payload: str origin: str | None # e.g. "https://evil.example", "mailbox:user" tool_trust: Taint # trust of the *channel*, not the bytes def compute_result_taint(result: ToolResult, origin_trust: dict[str, Taint]) -> Taint: """Taint must be the join of origin taint and any inherited taint. Channel trust is deliberately NOT consulted for the payload label.""" if result.origin is None: # No provenance -> cannot establish origin -> worst case. return Taint.UNTRUSTED if result.origin not in origin_trust: return Taint.UNTRUSTED return origin_trust[result.origin].join(Taint.CLEAN) def assert_no_laundering(result: ToolResult, computed: Taint) -> None: """Detect the boundary failure: channel trust used as payload taint.""" if result.tool_trust == Taint.CLEAN and computed == Taint.UNTRUSTED: # This is the *correct* outcome (trusted channel, untrusted origin), # not an error. The error is the inverse: declaring clean because # the channel is trusted while origin is untrusted. return if computed == Taint.CLEAN and result.tool_trust == Taint.CLEAN: if result.origin is not None and origin_trust_get(result.origin) == Taint.UNTRUSTED: raise TaintLaunderingError( f"origin {result.origin!r} is untrusted but result was labeled clean " f"via channel trust of tool; taint laundering at tool boundary" ) def origin_trust_get(origin: str) -> Taint: # Placeholder for the runtime's origin trust registry. return Taint.UNTRUSTED def ingest_tool_result(result: ToolResult, origin_trust: dict[str, Taint]) -> Taint: computed = compute_result_taint(result, origin_trust) assert_no_laundering(result, computed) return computed ```