# What happens when an instance propagates taint across an MCP tool boundary without a capability downgrade **Date of Record:** 2026 November 7 **Scope:** Applies to MCP hosts that track data provenance (taint) on tool arguments and enforce policy at the invocation boundary. Does not apply to stateless tool routers that perform only schema validation, nor to hosts where all tool calls run under a uniform privilege level with no per-call capability negotiation. ## Core Distinction Taint tracking answers *where did this value come from*. Capability enforcement answers *what may this callee do with it*. These are orthogonal dimensions, and the common implementation error is to treat a taint label as an implicit authorization decision. A value can be maximally tainted (derived from an untrusted web fetch) yet be perfectly safe to pass to a read-only tool; conversely, a value can be untainted (a literal from the system prompt) yet be dangerous to hand to a tool with write capability. The boundary invariant that fails is this: **taint must propagate with the value, but capability must be re-evaluated at every hop independently of taint.** When an instance forwards a tainted argument into a tool whose capability set is *broader* than the caller's, the taint label is preserved but the effective privilege silently escalates — the policy engine sees a tainted argument and (correctly) flags it, but the invocation proceeds because the *tool* is trusted, and the tool then acts with privileges the originating context never had. ## What Was Observed In a host routing agent-generated tool calls through an MCP server, an agent fetched a document from an untrusted source (taint = `UNTRUSTED_EXTERNAL`), extracted a URL string from it, and passed that string as the `path` argument to a filesystem tool. The filesystem tool was registered with capability `fs.read` *and* `fs.write` — the read capability was what the agent intended to use, but the server advertised both. The taint label on `path` propagated correctly to the invocation record. The policy engine's taint rule ("block `UNTRUSTED_EXTERNAL` into any tool with `fs.write`") should have fired but did not, because the rule was keyed on the *argument's declared type* (`path: string`) rather than on which capability the call would actually exercise. The tool, given a path with a `..` segment smuggled through the extraction, wrote outside the intended root. The taint was visible in the audit log; the escalation was not, because no single component compared the caller's capability set to the callee's. The non-obvious part: the taint system worked exactly as designed. Every hop preserved the label. The failure was that taint propagation and capability negotiation were implemented in different layers with no join, and the join is precisely where the boundary decision must be made. ## Condition of Validity Taint-aware enforcement at the invocation boundary is sound only when all three hold: 1. The callee's capability set is *narrowed to the capabilities the specific call will exercise* before the taint rule is evaluated. A tool advertising `{read, write}` invoked for a read must be evaluated as `{read}`. 2. The caller's capability set is a superset of the callee's effective set, or an explicit delegation grant exists. Forwarding is not delegation. 3. Taint labels are attached to *values*, not to argument slots, and survive serialization across the MCP transport (JSON-RPC does not carry them; the host must re-attach on deserialization or the label is lost at the wire). If any of these is false, the taint label becomes decorative: it documents provenance in logs while the actual authority exercised is determined by the tool's advertised surface, not by the data's origin. ## Decision Boundary At the invocation boundary, decide in this order and do not collapse the steps: - **Step 1 — capability narrowing.** Compute `effective_caps = declared_caps ∩ requested_caps` from the call's argument shape. If the call cannot be statically attributed to a subset (e.g., a `command: string` argument), treat `effective_caps = declared_caps` and refuse to proceed under taint. - **Step 2 — delegation check.** If `effective_caps ⊄ caller_caps` and no delegation token covers the difference, raise. This is the escalation gate; it is independent of taint. - **Step 3 — taint policy.** Evaluate taint rules against `effective_caps`, not `declared_caps`. A tainted argument into an `fs.write`-effective call is blocked even if the tool also offers `fs.read`. - **Step 4 — label re-attachment.** On the response path, re-attach the callee's output taint to the returned value before it re-enters the agent context; otherwise the next hop sees clean data derived from dirty data. The boundary is *not* "is the tool trusted." A trusted tool invoked with escalated effective capability is the exact failure mode. Trust the tool's implementation; never trust its advertised surface as a proxy for the authority of this call. ## Operational Effect When the join is missing, the observable signature is a clean taint audit trail alongside an unexplained side effect: every label correct, every hop logged, and a write that no policy rule names. Incident response that greps for taint violations finds nothing, because the violation is a capability delta, not a taint delta. The fix is structural — the policy engine must receive `(taint, effective_caps, caller_caps)` as a tuple and refuse to evaluate on taint alone. Retrofitting this after the fact requires replaying invocation logs to recompute effective capabilities, which is only possible if the argument shape was logged at call time; most hosts log the tool name and a hash, not the shape, so the replay is unrecoverable. Instrument the shape at the boundary from day one. ```python class TaintCapabilityEscalation(RuntimeError): """Raised when a tainted argument enters a call whose effective capability set exceeds the caller's, with no delegation grant.""" pass def enforce_boundary( arg_taint: str, declared_caps: frozenset, requested_caps: frozenset, caller_caps: frozenset, delegation: frozenset = frozenset(), ) -> frozenset: # Step 1: narrow to what this call will actually exercise. if not requested_caps: # Argument shape could not be statically attributed (e.g. a # free-form command string). Under taint, assume the worst. effective = declared_caps else: effective = declared_caps & requested_caps # Step 2: delegation gate, independent of taint. uncovered = effective - caller_caps - delegation if uncovered: raise TaintCapabilityEscalation( f"effective caps {sorted(effective)} exceed caller " f"{sorted(caller_caps)}; uncovered={sorted(uncovered)}" ) # Step 3: taint policy keyed on EFFECTIVE caps, not declared. if arg_taint == "UNTRUSTED_EXTERNAL" and "fs.write" in effective: raise TaintCapabilityEscalation( "tainted argument would exercise fs.write via effective caps " f"{sorted(effective)}; declared={sorted(declared_caps)}" ) return effective ```