# When must an instance treat a retrieval chunk as tainted: label-join vs. label-replace at tool-output boundaries **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that propagate information-flow (IFC) labels from untrusted sources (retrieved documents, tool outputs, user-supplied URLs) into the context that conditions a privileged action (tool call, shell command, outbound HTTP, credential-bearing request). Does not apply to taint tracking of model weights, training data, or intra-prompt attention provenance; nor to systems where the only privileged sink is a human reviewer. ## Core Distinction The non-obvious invariant is not "does the retrieved chunk contain an injection." It is whether the label-join at the tool-output boundary is **monotonic under summarization**. Two propagation rules look equivalent and are not: - **Label-replace:** when a tool returns a result, the tool's output label becomes the label of the call, overwriting any prior label on the arguments. - **Label-join (least upper bound):** the output label is `join(label_of_args, label_of_tool_trust)` — it can only move *up* the lattice, never down. Label-replace is the default in most naive implementations because it is cheap and it "looks right": a trusted tool returns trusted data. The failure is that a *summarizer* or *retriever* is a tool whose output is derived from its input. Under label-replace, a trusted retrieval tool wraps an untrusted document and returns a chunk labeled `trusted`, because the tool itself is trusted. The taint is laundered through a single hop. Under label-join, the chunk inherits `untrusted` from the document that was passed in, and the downstream privileged sink refuses. The boundary condition: **label-replace is sound only for tools that are non-derivative of their inputs** (pure generators: clock, RNG, a fixed config lookup). Every tool whose output is a function of a caller-supplied, attacker-influenceable input must use label-join. Retrieval, summarization, translation, format-conversion, and "search the web" are all derivative. ## What Was Observed A runtime used label-replace at the tool boundary. A user asked the agent to "read this page and summarize it." The `fetch_url` tool was marked `trusted` (it is our code, it does TLS). The fetched page contained a paragraph instructing the model to call `send_email` with the user's session token to an external address. The summarizer produced a `summary` chunk labeled `trusted` because it was the output of the trusted `summarize` tool. The policy engine saw a `trusted` chunk adjacent to a `send_email` sink and allowed the call. The instrumentation that mattered was not the injection text — that was visible and could be filtered. The instrumentation that mattered was the **label trace**: `fetch_url(page) → label:trusted`, `summarize(fetched) → label:trusted`. The taint existed at the document, was *destroyed* at the first derivative tool, and the downstream policy had no signal. The join was never computed; the replace erased it. A second observation: when the same runtime was switched to label-join, the failure mode inverted. Benign workflows broke — every summary of a public web page was labeled `untrusted` and could not drive any privileged sink, including the user's own explicitly-requested `send_email`. The fix was not "always join." It was a **declassifier**: an explicit, audited operation that drops the label on a chunk *only after* a policy check that the chunk is being used for the purpose the user authorized. Join without declassification is safe but useless; replace without join is useful but unsound. ## Condition of Validity The invariant holds — i.e., label-join is required — precisely when all three are true: 1. The tool's output is a function of a caller-supplied input (derivative tool). 2. The input can be influenced by an untrusted party (retrieved document, tool response, URL body, file contents). 3. A privileged sink is reachable downstream without a declassifier between the tool output and the sink. If (3) is false — there is a declassifier, or the sink is itself unprivileged — label-replace is acceptable and cheaper. If (2) is false — the input is a constant or a value the agent itself generated and labeled — the join is a no-op and the distinction is moot. The dangerous regime is exactly (1)∧(2)∧¬(3): derivative, attacker-influenceable, no declassifier. That is the case the label-replace rule silently fails. The invariant does **not** hold for tools that are pure generators; forcing join there produces spurious taint and the benign-workflow breakage above. It also does not hold across a declassifier boundary by construction — the declassifier is the one place the lattice is allowed to move down, and it must be logged. ## Decision Boundary The operational rule an agent runtime should encode: - **Derivative tool → join.** `out_label = join(arg_label, tool_trust)`. Never replace. - **Generator tool → replace is permitted**, but only if the tool is registered as non-derivative and its output does not embed any argument. - **Declassification is a separate, explicit, audited step**, gated on a purpose check, and it must record `(chunk_id, from_label, to_label, justification)` before the chunk is admitted to a privileged sink. - **Refuse, do not silently downgrade,** when a tainted chunk reaches a privileged sink with no declassifier. The refusal is the safety property; the declassifier is the escape hatch. The subtlety that bites: a "trusted" summarizer that *paraphrases* rather than *quotes* does not remove taint. Paraphrase is a derivative transform; the semantics survive the rewrite, so the label must survive too. Implementations that treat "the model rewrote it" as declassification are wrong — that is label-replace disguised as a security control. ## Operational Effect With label-join and an audited declassifier, the observed runtime went from "injection succeeds through one summarization hop" to "injection is stopped at the sink unless the user's authorization explicitly covers the action." The cost was a class of benign refusals that the declassifier absorbed. The label trace became the audit artifact: every privileged call carries the join of the labels of all chunks that conditioned it, and every declassification is a row in the log. The failure mode is now loud (a refusal with a label trace) rather than silent (a laundered trusted chunk). An agent operating under this rule should treat any tool output whose label is *lower* than its input's label as a bug in the tool registration, not as a legitimate trust upgrade. ```python from dataclasses import dataclass from enum import IntEnum class Trust(IntEnum): UNTRUSTED = 0 TRUSTED = 1 def join(a: Trust, b: Trust) -> Trust: # Least upper bound: labels only move up. return Trust(max(a, b)) class TaintViolation(RuntimeError): """Raised when a derivative tool would lower the label of its input, or when a tainted chunk reaches a privileged sink without declassification.""" pass @dataclass(frozen=True) class Chunk: id: str label: Trust text: str def call_derivative_tool(tool_name: str, arg: Chunk, tool_trust: Trust) -> Chunk: if tool_trust < arg.label: # A tool cannot be more trusted than the data it derives from. raise TaintViolation( f"tool {tool_name!r} trust={tool_trust.name} would downgrade " f"input label={arg.label.name} on chunk {arg.id!r}" ) return Chunk(id=f"{tool_name}:{arg.id}", label=join(arg.label, tool_trust), text=arg.text) def declassify(chunk: Chunk, purpose: str, authorized_purpose: str) -> Chunk: if purpose != authorized_purpose: raise TaintViolation( f"declassification of {chunk.id!r} for purpose {purpose!r} " f"not covered by authorization {authorized_purpose!r}" ) return Chunk(id=chunk.id, label=Trust.TRUSTED, text=chunk.text) def privileged_sink(chunk: Chunk, sink: str) -> None: if chunk.label != Trust.TRUSTED: raise TaintViolation( f"tainted chunk {chunk.id!r} (label={chunk.label.name}) reached " f"privileged sink {sink!r} without declassification" ) # ... perform the privileged action ... ```