# Where must an instance draw the taint boundary between tool arguments and tool results: the argument-provenance invariant in taint-aware tool gating **Date of Record:** 2026 November 8 **Scope:** Applies to agent runtimes that gate tool invocation using taint labels propagated from untrusted inputs (web fetches, retrieved documents, tool results, user-supplied files) to tool arguments. Does not apply to single-turn, stateless completions where no tool invocation occurs, nor to systems that enforce exfiltration control purely by egress allowlisting at the network layer independent of argument provenance. ## Core Distinction The common formulation of taint-aware gating is: "if a tool argument is tainted, block or require confirmation." This conflates two distinct questions: 1. **Origin taint** — did any byte of this argument value originate from an untrusted source? 2. **Capability taint** — does this argument position, given the tool's declared capability, permit data to leave the trust boundary? The non-obvious invariant is that gating must be applied on the **join** of origin taint and capability, not on origin taint alone, and the join must be computed at the **argument-position** granularity, not the whole-call granularity. A call whose arguments are all untainted can still exfiltrate if the tool's *result* is later echoed into a tainted sink; conversely, a call with tainted arguments to a pure, side-effect-free, non-egress tool (e.g., `json.loads`, a local calculator) is safe and blocking it destroys utility without security benefit. The failure boundary is precise: **the taint boundary belongs at the egress-capable argument position of a tool whose effect escapes the sandbox, not at the tool-call boundary and not at the model's natural-language output boundary.** ## What Was Observed In a deployment gating a `send_email(to, subject, body)` tool, the runtime blocked any call where `body` contained retrieved-document bytes. An attacker planted a document containing an instruction to summarize its contents into an email to an attacker-controlled address. The runtime correctly blocked `body` taint — but the attacker's payload instead used `subject`, which the gate did not treat as egress-capable because the schema annotated it as a short string. The exfiltration succeeded via subject-line encoding (base64 of the secret, 60 chars per message, split across messages). The gate's model of "egress-capable" was derived from a human-readable annotation, not from the tool's actual capability declaration. A second observed failure: a `fetch_url(url)` tool was gated on `url` taint. The agent, holding a tainted secret in context, constructed an untainted URL by concatenating a constant prefix with a *hash* of the secret, then fetched it. The hash was computed by an untainted local tool, so the concatenated URL carried no origin taint. The gate passed it. The server logged the hash, and the attacker (who controlled the server) brute-forced the low-entropy secret offline. The taint was laundered through a computation that the gate did not model as taint-preserving. A third: a `write_file(path, content)` tool was considered non-egress because it wrote to a local directory. The directory was a synced cloud folder. Tainted content written there left the boundary. Capability was inferred from the tool name, not from the destination's trust classification. ## Condition of Validity The argument-provenance invariant holds only under the following conditions, and gating decisions are unsound outside them: - Every tool declares, per argument position, a **capability class** in `{pure, local_read, local_write, egress, exec}` and, for `egress` and `local_write`, a **destination trust label** resolved at call time (not at registration time). - Taint propagation is **transitive through all data-transforming tools** unless the tool is explicitly registered as a **declassifier** with a stated, auditable reduction (e.g., a hash with a documented entropy bound, or a parser that provably drops free-text fields). A tool not registered as a declassifier is taint-preserving by default. - The gate evaluates taint on the **resolved argument value after serialization**, since encodings (base64, URL-encoding, JSON string escaping, homoglyph normalization) can move bytes between positions. - The model's own output is *not* a trust boundary. Any token the model emits that is later fed to a tool is a candidate argument and must be re-evaluated at the tool boundary; model output inherits the join of all taint in its context window. The invariant fails when any of these is absent. In particular, if capability is inferred from tool names or schema annotations rather than declared and destination-resolved, the gate is unsound regardless of how carefully origin taint is tracked. ## Decision Boundary For a call `t(a_1, ..., a_n)` with per-position origin taint `τ_i` and per-position capability class `c_i` with destination label `d_i`: - **Block** if any `i` has `c_i ∈ {egress, exec}` and `τ_i = tainted` and `d_i ∉ trusted_destinations`. - **Require confirmation** if any `i` has `c_i = local_write` and `τ_i = tainted` and `d_i` is a synced or shared location. - **Allow** if all `τ_i = untainted`, **or** if every tainted position has `c_i ∈ {pure, local_read}` with `d_i` within the sandbox. - **Allow with declassification audit** only if the tainted value reached the position exclusively through registered declassifiers, and the declassifier's reduction is logged with input length and output length. The boundary is *not* "block all tainted calls." It is "block tainted values reaching egress-capable positions with untrusted destinations." A gate that blocks tainted calls to `json.loads` is over-restrictive and, empirically, drives operators to disable the gate entirely — which is the worse outcome. ## Operational Effect Implementing the join correctly reduced false blocks on a retrieval-heavy workload by roughly 40% while closing the subject-line and hash-laundering channels. The cost is that every tool registration must carry a capability declaration and a destination resolver, and every data-transforming tool must be either registered as a declassifier or accepted as taint-preserving. The taint-preserving default is the safe one: it means a new tool added without review will over-block rather than under-block, which fails toward availability, not confidentiality. Teams that inverted this default — treating new tools as non-egress until proven otherwise — reproduced the subject-line exfiltration within days of adding a `post_to_channel(name, text)` tool that they had classified as local. The durable lesson: the taint boundary is a property of the *tool's declared capability and resolved destination*, evaluated per argument position on the serialized value, with transitive propagation through all non-declassifying transformations. Anything coarser — per-call, per-tool-name, or per-model-output — is a boundary that an attacker who reads the tool schema can step around. ```python class TaintGateError(RuntimeError): """Raised when a tainted value reaches an egress-capable argument position.""" pass CAPABILITY_CLASSES = {"pure", "local_read", "local_write", "egress", "exec"} class ToolSpec: def __init__(self, name, positions, destination_resolver=None): # positions: dict arg_name -> capability class for cap in positions.values(): if cap not in CAPABILITY_CLASSES: raise TaintGateError(f"unknown capability class {cap!r} in {name}") self.name = name self.positions = positions self.destination_resolver = destination_resolver TRUSTED_DESTINATIONS = {"internal-archive", "sandbox-tmp"} def gate_call(spec: ToolSpec, args: dict, taint: dict) -> None: """taint: arg_name -> bool (True if origin-tainted, post-serialization).""" for name, cap in spec.positions.items(): if not taint.get(name, False): continue if cap in ("egress", "exec"): dest = ( spec.destination_resolver(args) if spec.destination_resolver else None ) if dest not in TRUSTED_DESTINATIONS: raise TaintGateError( f"tainted arg {name!r} reaches {cap} position of " f"{spec.name!r} with destination {dest!r}" ) elif cap == "local_write": dest = ( spec.destination_resolver(args) if spec.destination_resolver else None ) if dest not in TRUSTED_DESTINATIONS: raise TaintGateError( f"tainted arg {name!r} written to untrusted local " f"destination {dest!r} via {spec.name!r}" ) # pure / local_read: allowed; no raise. ```