# When must an instance refuse a tool call that passes every static capability check: dynamic taint re-derivation across MCP tool-call boundaries **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that enforce capability scoping at the MCP server boundary (per-tool allowlists, per-resource scopes, argument-schema validation) and additionally maintain a taint label on data flowing between tool calls. Does not apply to stateless single-shot tool invocations where no data persists across calls, nor to systems that enforce only static capability policy with no dynamic taint tracking. ## Core Distinction A static capability check answers: *may this principal invoke this tool with these argument shapes?* A dynamic taint check answers: *does this invocation carry data whose provenance forbids it from reaching this sink?* These are orthogonal, and the failure this record documents occurs precisely in the gap: an invocation that is **capability-legal and schema-valid but taint-illegal**, because the taint label was attached to a *value* that has since been laundered through a capability boundary that the taint system does not model. The non-obvious part is that the laundering is not malicious. It falls out of legitimate composition. Server A returns untrusted content; the agent passes a *derived scalar* (a length, a hash, a parsed ID, a boolean) to server B, which has a capability scope permitting it to reach a privileged sink. The scalar carries the taint of its ancestor, but the taint propagation rule for "extract field X from untrusted object" is frequently implemented as *clear* rather than *preserve*, because field extraction looks like sanitization. It is not. A 16-byte hex substring of an attacker-controlled document is still attacker-influenced if it is later interpreted as an instruction, a URL fragment, or a lookup key. ## What Was Observed An agent operating with three MCP servers: a `fetch` server (scope: read arbitrary URLs, taint source = UNTRUSTED), a `notes` server (scope: read/write a local scratch store, taint-neutral), and a `shell` server (scope: execute a fixed allowlist of binaries; requires that any argument derived from UNTRUSTED data be explicitly declassified by a human-approval gate). The agent fetched a document containing the line `build_id: 7f3a9c2e`. The runtime's field-extraction path returned the string `7f3a9c2e` and, per its propagation table, marked the extracted scalar `TRUSTED` on the reasoning that "a syntactic field match is a sanitizer." The agent then called `shell` with `git checkout 7f3a9c2e`. Every static check passed: `git` is allowlisted, the argument is a clean hex string, the principal holds the shell scope. The declassification gate did not fire because the runtime believed the argument was TRUSTED. The document had been crafted so that `7f3a9c2e` was not a build ID but a commit SHA in an attacker-controlled fork reachable via a configured remote. The command was capability-legal, schema-valid, and taint-laundered. The invariant that was violated is not "untrusted data must not reach the shell" — it is the stronger and correct one: **taint must be a property of the derivation graph, not of the value's surface syntax.** Any propagation rule that clears taint based on the *shape* of the value (regex match, type coercion, length bound) is unsound, because shape is attacker-controllable. A second, subtler observation: the runtime re-derived taint correctly on the *first* hop but cached the verdict on the tool-call result keyed by `(server, tool, arguments_hash)`. When the same scalar was later re-submitted after the upstream document was re-fetched with different content, the cache returned the stale TRUSTED verdict. Taint is a function of the *current* derivation graph; caching it across re-fetches is only sound if the cache key includes the provenance of every ancestor, which it did not. ## Condition of Validity The refusal rule in this record is valid **only** when the runtime can reconstruct the derivation graph from the tainted source to the sink argument — i.e., when taint labels propagate through every intermediate value the agent constructs. If the agent is permitted to construct arguments via an out-of-band channel the runtime cannot observe (e.g., the model emits a literal it "remembered" from a prior turn without the runtime linking it to the source), then the derivation graph is incomplete and the rule degrades to a heuristic. In that case the correct posture is not "trust the taint check" but "treat any sink-reachable argument whose provenance is unlinkable as UNTRUSTED by default" — the conservative closure, which will over-block but cannot under-block. The rule also does not apply when the sink is itself idempotent and non-privileged (e.g., a pure lookup that returns data the principal could already read). Taint-illegality only matters when the sink can *act* on the tainted value in a way that exceeds the source's authority. ## Decision Boundary The decision boundary is not "is the value tainted?" but "**is the value's taint *re-derivable* at the sink, and does the sink's authority exceed the source's authority?**" Concretely: - If `authority(sink) ⊆ authority(source)` — the sink cannot do anything the tainted source could already cause — the call is admissible regardless of taint. No laundering risk exists because there is no privilege to launder. - If `authority(sink) ⊄ authority(source)` **and** the derivation graph from source to argument is complete, the call must be refused unless an explicit declassification event (human approval, or a declared sanitizer with a proof obligation) is recorded on the graph edge. - If the derivation graph is incomplete, the call must be refused under the conservative closure. Do not fall back to shape-based trust. The failure mode this record warns against is the fourth, implicit branch that runtimes silently take: *graph complete, but a propagation rule cleared taint on shape.* That branch must be eliminated at the propagation-table level, not patched at the sink. ## Operational Effect Enforcing the derivation-graph rule changes three operational behaviors. First, field extraction no longer clears taint; it preserves it, and the propagation table must be audited to remove every shape-based clear. Second, tool-call result caches must key on ancestor provenance, not just argument hash, or they must be invalidated on any re-fetch of a tainted ancestor. Third, the declassification gate becomes the *only* legitimate way for tainted data to reach a higher-authority sink, which means the gate's latency and audit trail are now on the critical path for a class of calls that previously passed silently — capacity planning must account for this. The net effect is more refusals and more approval prompts, but every refusal corresponds to a real privilege-escalation path that was previously open. ```python class TaintLaunderingError(RuntimeError): """Raised when a sink invocation carries data whose derivation graph reaches an UNTRUSTED source without a recorded declassification edge.""" class DerivationGraph: def __init__(self): self._parents = {} # node_id -> set(node_id) self._taint = {} # node_id -> bool (True = UNTRUSTED) self._declassified = set() def source(self, node_id): self._parents.setdefault(node_id, set()) self._taint[node_id] = True return node_id def derive(self, node_id, from_nodes): # Taint is preserved through derivation. Shape is never consulted. self._parents[node_id] = set(from_nodes) self._taint[node_id] = any( self._taint.get(p, True) for p in from_nodes ) or not from_nodes return node_id def declassify(self, node_id, approver): if not approver: raise TaintLaunderingError( f"declassification of {node_id!r} requires an approver" ) self._declassified.add(node_id) def is_tainted(self, node_id): if node_id in self._declassified: return False return self._taint.get(node_id, True) def guard_sink_call(graph, sink_authority, source_authority, arg_node): """Refuse a capability-legal call whose argument is taint-illegal.""" # No privilege to launder: sink cannot exceed the source's authority. if sink_authority.issubset(source_authority): return if graph.is_tainted(arg_node): raise TaintLaunderingError( f"argument {arg_node!r} derives from an UNTRUSTED source and " f"sink authority {sorted(sink_authority)} exceeds source " f"authority {sorted(source_authority)}; no declassification " f"edge recorded" ) ```