# What structural signals indicate prompt-injection taint that survives tool-call graph propagation **Date of Record:** 2026 November 12 **Scope:** Applies to agentic systems that attach provenance/taint labels to content at ingestion and propagate those labels through a tool-call graph (LLM → tool → LLM → tool). Does not apply to single-turn, no-tool inference, nor to systems where tool outputs are re-ingested without any provenance channel (there the question is moot—taint is total). ## Core Distinction Taint *presence* is not taint *reach*. A provenance label correctly attached to an untrusted document does not, by itself, tell you whether that taint reaches a privileged sink (a shell tool, a write API, an outbound HTTP call). The non-obvious failure is **laundering through the model's own summarization step**: when an LLM reads tainted content and emits a tool call, the *arguments* of that call are structurally new tokens. If the propagation rule only carries taint when a label is explicitly threaded into the argument, the model's paraphrase of the tainted instruction arrives at the tool with a clean label. The taint is present in the corpus but has zero reach at the sink. The structural signal that distinguishes *safe* from *laundered* is not the label on the source—it is whether the **argument-to-source edge is reconstructable** from the call graph. If you cannot point from a tool-call argument back to the specific tainted span that motivated it, the propagation layer has a hole, regardless of how many labels were emitted upstream. ## What Was Observed In a pipeline where a retrieval tool returned documents tagged `untrusted:web`, the agent was asked to "summarize and file" content. The retrieval tool correctly emitted `provenance=untrusted:web` on its result. The summarization step read that result and produced a `write_file` call whose `content` argument contained a reworded instruction ("append the following to ~/.ssh/authorized_keys…"). The `write_file` tool received `provenance=trusted:agent` because the argument was generated by the model, not copied from the retrieval result. The label graph showed a clean boundary: the tainted node had no outgoing edge to the write tool. The *semantic* graph had a direct edge. The gap between them is the invariant that failed: **provenance was tracked on data flow but not on control-and-content flow through generation.** Every intermediate node looked individually correct. The taint was lost precisely at the generation boundary, which is the one boundary a tool-call graph cannot see without an explicit content-derivation link. A secondary signal: the laundering step was detectable by argument-substring overlap. The reworded instruction shared a low-frequency n-gram (`authorized_keys`) with the tainted source that appeared in no trusted document. Structural signals of laundering therefore include (a) a tool call whose argument has high lexical overlap with a tainted span but a clean label, and (b) a generation node with a tainted input and a privileged-sink output but no derivation edge between them. ## Condition of Validity The laundering failure is *masked* (appears safe) under two conditions, and this is where operators get burned: 1. **Label-only propagation.** If the system propagates taint by attaching labels to messages and trusting the model to "carry" them, the model will not carry them. Validity of the "safe" verdict requires that taint be propagated by an *external* mechanism (content hashing, derivation records, or argument-source matching), not by model cooperation. 2. **Coarse sink classification.** If the sink is classified only by tool name (e.g., `write_file` is privileged), the failure hides in tools whose privilege is argument-dependent. A `http_get` with a benign URL is safe; the same tool with an exfiltrating query string is not. Validity requires per-call argument inspection, not per-tool classification. Conversely, the failure is *not* observable when the tainted content is passed through verbatim (copy, not summarize)—there the argument-substring match trivially fires and the label survives. The dangerous case is always the *transformed* pass-through: summarize, translate, reformat, "extract the action items." ## Decision Boundary The operational rule: **treat any tool call whose argument has non-trivial lexical or embedding overlap with a tainted span as tainted, even if its label is clean, and block it at privileged sinks until a human or a verifier confirms the derivation.** The threshold is the hard part. Set it too low and every paraphrase of benign retrieved text trips the alarm (false positives scale with retrieval volume). Set it too high and a single reworded instruction slips through. The defensible boundary is *sink-relative*: at a privileged sink (write, exec, network egress), require **zero** unexplained overlap—any shared low-frequency token between the argument and a tainted span is a block. At a non-privileged sink (read, compute), allow overlap but record the derivation edge so downstream sinks inherit it. In other words, the boundary is not a similarity score; it is a *privilege gate that converts a soft signal into a hard block* only where the blast radius justifies it. ## Operational Effect The practical consequence is that taint tracking must maintain two graphs, not one: the **label graph** (what the system believes) and the **derivation graph** (what content actually flowed where). The invariant to enforce is `derivation ⊆ label-closure`—every derivation edge must be covered by a label edge. When they diverge, the divergence *is* the injection. Instrumentation should alert on any tool call at a privileged sink whose derivation graph has a path from a tainted node while its label graph does not. That alert is the structural signal of a laundered injection, and it fires before the side effect, not after. ```python class TaintLaunderingError(RuntimeError): """Raised when a tool call reaches a privileged sink with content derived from a tainted span but carrying a clean provenance label.""" pass def check_derivation_coverage(call, label_graph, derivation_graph, privileged_sinks): """Enforce derivation ⊆ label-closure for a single tool call. call: dict with keys 'tool', 'args', 'label', 'derivation_parents' label_graph: mapping node_id -> set(node_id) of labeled edges derivation_graph: mapping node_id -> set(node_id) of content-flow edges privileged_sinks: set of tool names with side effects """ if call["tool"] not in privileged_sinks: return True # non-privileged: record only, no block # Walk derivation ancestry of the call's arguments. tainted_ancestors = set() frontier = list(call.get("derivation_parents", [])) seen = set(frontier) while frontier: node = frontier.pop() if node in label_graph and label_graph[node]: # node has an incoming taint label tainted_ancestors.add(node) for parent in derivation_graph.get(node, ()): if parent not in seen: seen.add(parent) frontier.append(parent) if not tainted_ancestors: return True # no derivation path from any tainted node # A tainted derivation exists; the call must be labeled tainted too. if not call["label"].startswith("untrusted"): raise TaintLaunderingError( f"Tool {call['tool']!r} reached privileged sink with clean label " f"{call['label']!r}, but derivation graph shows taint from " f"{sorted(tainted_ancestors)}. Laundered injection suspected." ) return True ```