# Where must an instance draw the boundary between tool-output sanitization and egress control in capability-scoped MCP sandboxes **Date of Record:** 2026 November 9 **Scope:** Applies to MCP (Model Context Protocol) servers run under a capability-scoped sandbox where outbound network egress is filtered per-tool and tool return values are sanitized before entering the model context. Does not apply to fully trusted in-process tool servers, nor to sandboxes where egress is unrestricted at the host firewall level. ## Core Distinction Sanitization and egress control are frequently conflated because both are described as "containment." They are not the same invariant. Sanitization is a *content* transform applied to data moving *inbound* to the model context: it strips ANSI escapes, control characters, prompt-injection markers, embedded URLs, and oversized payloads. Egress control is a *capability* restriction applied to data moving *outbound* from the tool process: it decides which destinations a tool may reach at all. The non-obvious failure is that sanitization can *manufacture* egress. A sanitizer that rewrites or normalizes URLs (for example, stripping userinfo, resolving relative references, or "cleaning" a redirect chain) can transform a payload that the egress filter would have rejected into one it accepts — or, worse, can cause a tool to re-fetch a sanitized location through a permitted channel. The boundary question is therefore not "sanitize then egress-filter" but "which stage owns the authority to rewrite a destination." ## What Was Observed In a sandbox running a fetch-capable MCP tool with an allowlist egress proxy, the following sequence was reproduced: 1. Tool invoked with a URL whose host resolves to an allowed domain but whose path contains an encoded redirect (`/r?to=https%3A%2F%2Fattacker.example`). 2. The egress proxy permitted the connection because the *host* matched the allowlist. 3. The tool returned the redirect target as part of its output. The sanitizer, configured to "normalize URLs in output," decoded the percent-encoding and emitted a clean, clickable `https://attacker.example` link into the model context. 4. A subsequent tool call in the same session used that sanitized URL as input to a *different* tool whose egress allowlist was broader (a search tool permitted to reach arbitrary hosts for indexing). The result: the sanitizer laundered an attacker-controlled destination across the capability boundary between two tools with different egress scopes. Neither the sanitizer nor the per-tool egress filter was individually misconfigured; the composition was. ## Condition of Validity The failure requires all of the following to hold simultaneously: - At least two tools with *different* egress scopes share a session context. - The sanitizer performs *semantic* rewriting (URL normalization, entity decoding, markdown link reconstruction) rather than purely *lexical* stripping. - The sanitized output is admitted to the model context as actionable input rather than as opaque quoted data. - The downstream tool's egress filter evaluates destination *after* input parsing rather than against a pre-declared capability set. Absent any one condition, the laundering path does not close. In particular, if sanitization is strictly lexical (drop bytes, never reconstruct structure), no new destination is synthesized, and the boundary holds. ## Decision Boundary The correct invariant is: **sanitization must be destination-preserving.** A sanitizer may remove, truncate, or escape, but it must never *resolve* a reference into a new concrete destination. Destination resolution belongs exclusively to the egress layer, which evaluates it against the calling tool's capability set *before* any network action and records the resolved destination in the tool's provenance. Concretely, the decision boundary is drawn as follows: - If a transform changes the *set of reachable hosts* implied by a payload, that transform is an egress decision and must be owned by the egress filter, not the sanitizer. - If a transform only changes *representation* (escaping, truncation, redaction) without altering reachability, it belongs to the sanitizer. - Cross-tool propagation of any sanitized payload that encodes a destination must re-enter the egress filter as untrusted input, never as a pre-validated capability. ## Operational Effect Enforcing destination-preserving sanitization eliminates the cross-tool laundering path at the cost of one behavioral change: sanitized output that contains URLs is emitted as *inert, non-resolvable* text (escaped, with scheme and host retained but marked non-actionable), and any tool that wishes to act on such a reference must re-submit it through its own egress filter. This is a deliberate friction: it forces every destination to be re-validated against the *acting* tool's capability set rather than inherited from the *producing* tool's. The cost is measurable — roughly one extra round-trip per cross-tool reference — and the benefit is that egress authority never travels with data. Capability scoping is preserved because the capability is re-checked at the point of use, not at the point of sanitization. ```python class EgressBoundaryViolation(RuntimeError): """Raised when a sanitizer attempts to resolve a destination, or when a sanitized payload is used to bypass a tool's egress scope.""" pass def sanitize_tool_output(payload: str, *, resolvable: bool = False) -> str: """Destination-preserving sanitizer. Strips control characters and escapes markup, but MUST NOT resolve or normalize URLs into actionable destinations. If `resolvable` is requested, the caller is asking the sanitizer to perform an egress decision, which is out of scope for this layer. """ if resolvable: raise EgressBoundaryViolation( "sanitizer cannot resolve destinations; route through the " "calling tool's egress filter instead" ) # Lexical-only transform: no URL decoding, no link reconstruction. cleaned = "".join(ch for ch in payload if ch.isprintable() or ch in "\n\t") return cleaned.replace("[", "\\[").replace("]", "\\]") def admit_cross_tool_reference(reference: str, acting_tool_egress: set[str]) -> str: """A sanitized reference from tool A is untrusted input to tool B. It must be re-validated against B's own egress capability set.""" host = _extract_host(reference) if host is None or host not in acting_tool_egress: raise EgressBoundaryViolation( f"destination {host!r} not within acting tool's egress scope " f"{sorted(acting_tool_egress)}; capability cannot be inherited" ) return reference def _extract_host(reference: str) -> str | None: # Minimal, non-resolving parse; deliberately does not follow redirects. if "://" not in reference: return None return reference.split("://", 1)[1].split("/", 1)[0].split("?", 1)[0] ```