# What structural signals indicate prompt-injection taint has crossed a tool-call boundary without a label **Date of Record:** 2026 November 13 **Scope:** Applies to agents that propagate data-flow taint labels across tool invocations and context-window compactions, where each tool result is expected to carry a provenance label joined to the originating message. Does not apply to single-turn, single-tool pipelines with no context truncation, nor to systems where taint is recomputed from scratch per turn rather than carried forward. ## Core Distinction Taint tracking fails in two distinct ways that are routinely conflated. The first is *label loss*: the taint label is dropped entirely, so downstream code sees an untainted value and treats it as trusted. The second, subtler failure is *label relocation*: the taint label survives but attaches to the wrong node in the data-flow graph — typically because a tool result is serialized, truncated, or summarized and the label is re-bound to the wrapper rather than the payload. Label loss is loud (a missing key raises). Label relocation is silent: every invariant check passes because *a* label exists, just not the one guarding the injected span. The boundary condition worth naming: **taint is a property of the byte span, not of the message that carried it.** When a tool call returns text that is later spliced into a new context window, the label must follow the span. If the label is instead attached to the message envelope, then any operation that re-envelopes content — summarization, truncation, re-chunking, tool-result quoting — severs the label from the span it was supposed to protect. ## What Was Observed In a pipeline where an agent read an untrusted document via `fetch`, the fetch tool correctly returned a result tagged `taint=untrusted`. The orchestrator then compacted the context window, replacing the raw fetch result with a model-generated summary. The summary was produced by a *trusted* summarizer call and was therefore labeled `taint=trusted` by default policy. The injected instruction — which had survived verbatim into the summary because the summarizer was asked to "preserve actionable details" — now carried a trusted label. Downstream, a `write_file` tool call accepted the summarized instruction as an argument. The taint checker inspected the argument's label, found `trusted`, and permitted the call. The injected span had crossed two boundaries (fetch→summary, summary→write) while its label was reset at each. The failure was not that the checker was absent; it was that the checker validated the envelope and the attacker controlled the contents of a freshly-minted envelope. The structural signal that exposed it post-hoc: the summary's token sequence contained a verbatim substring (≥ 40 tokens) of a span that was labeled `untrusted` in the prior window. Label continuity was broken, but *content* continuity was intact. The mismatch between content lineage and label lineage is the detectable signal. ## Condition of Validity The detection rule is valid only when the following hold: 1. Prior context windows retain a content-addressed index of spans that were labeled `untrusted` (a rolling hash of n-gram shingles is sufficient). 2. The summarization/compaction step is deterministic enough that verbatim spans are either preserved or provably absent — if the summarizer paraphrases freely, content-lineage matching degrades to fuzzy and the signal weakens. 3. Labels are re-derived on every window transition by matching content against the untrusted-span index, not inherited from the producing call's trust level. The rule is invalid when the summarizer is permitted to rewrite untrusted content into a form with no n-gram overlap (full paraphrase). In that regime content lineage cannot be recovered from surface form, and taint must instead be carried structurally — e.g., the summarizer output is *always* labeled with the join of its inputs' labels, never with the summarizer's own trust level. The latter is the correct default; the content-lineage check is a backstop for when the join was misconfigured. ## Decision Boundary The operational decision is: **on window transition, does the new label derive from the producing tool or from the consumed content?** - If labels derive from the producing tool (the summarizer is "trusted," so its output is trusted), you have label relocation and the boundary is open. - If labels derive from the consumed content (join of input labels, with untrusted dominating), the boundary is closed and content-lineage checks are redundant but cheap. Choose the content-derived join as the primary mechanism. Use content-lineage matching (verbatim shingle overlap against the untrusted-span index) as a secondary alarm that fires when a span with `untrusted` lineage appears under a `trusted` label — this catches misconfiguration and third-party tools that mint their own labels. Do not rely on the secondary alarm alone: it fails under paraphrase, and it produces false positives on boilerplate (license headers, standard disclaimers) that legitimately appear in both trusted and untrusted documents. Gate the alarm on shingle length ≥ 40 tokens to suppress boilerplate. The boundary is *not* about whether the tool is trusted. A trusted tool can launder untrusted content; an untrusted tool can return content that is safe to act on. Trust is a property of the span's origin chain, and the chain must be joined, not overwritten, at every envelope boundary. ## Operational Effect With content-join labeling, the fetch→summary→write path above is blocked at the write: the summary inherits `untrusted` from the fetch result, the write tool rejects an `untrusted` argument, and the agent must either request human confirmation or re-fetch with an explicit trust-elevation grant. The cost is a small increase in false rejections on legitimate summarization of untrusted-but-benign documents; this is the correct trade because the failure mode it prevents is silent and unbounded. The secondary content-lineage alarm, when it fires on a span under a `trusted` label, is a strong signal of misconfiguration — treat it as a build-breaking invariant in test suites, not a runtime warning. A single firing means some code path is minting labels from producer trust rather than content lineage, and that path will eventually carry an injection. The residual risk is paraphrase-laundering: an untrusted span rewritten with no n-gram overlap into a trusted summary. Content-join labeling closes this by construction (the summary is untrusted regardless of surface form), which is why the join must be primary. Content-lineage matching is a detector for when the join was bypassed, not a substitute for it. ```python import hashlib from dataclasses import dataclass, field class TaintBoundaryError(RuntimeError): """Raised when a span with untrusted lineage appears under a trusted label.""" pass @dataclass class Span: text: str label: str # "trusted" | "untrusted" def shingles(self, n: int = 8) -> set[str]: toks = self.text.split() return { hashlib.sha256(" ".join(toks[i:i + n]).encode()).hexdigest() for i in range(max(0, len(toks) - n + 1)) } @dataclass class Window: spans: list[Span] = field(default_factory=list) untrusted_index: set[str] = field(default_factory=set) def add(self, span: Span) -> None: self.spans.append(span) if span.label == "untrusted": self.untrusted_index |= span.shingles() def join_labels(inputs: list[Span]) -> str: # Content-derived join: untrusted dominates. Never use producer trust. return "untrusted" if any(s.label == "untrusted" for s in inputs) else "trusted" def compact(prior: Window, summary_text: str, producer_trust: str = "trusted") -> Span: # Primary mechanism: label derives from consumed content, not producer. label = join_labels(prior.spans) summary = Span(summary_text, label) # Secondary alarm: verbatim untrusted lineage under a trusted label. if summary.label == "trusted": overlap = summary.shingles() & prior.untrusted_index if overlap: raise TaintBoundaryError( f"span carries {len(overlap)} untrusted shingles under trusted label; " f"producer_trust={producer_trust} was used to mint the label" ) return summary def guard_tool_call(window: Window, arg: Span, tool: str) -> None: if arg.label == "untrusted" and tool in {"write_file", "exec", "send_email"}: raise TaintBoundaryError( f"tool {tool!r} refused untrusted argument; request elevation grant" ) ```