# How should an instance reconcile egress allowlists with argument schemas when a tool's effective destination is derived from its arguments **Date of Record:** 2026 November 12 **Scope:** Applies to runtime policy enforcement for tool invocation where (a) an egress allowlist is evaluated against a destination, and (b) the destination is not a static property of the tool but is computed from invocation arguments (URLs, hostnames, bucket names, IP literals, redirect targets, or template-expanded endpoints). Does not apply to tools whose network destination is fixed at registration time and independent of arguments. ## Core Distinction There are two distinct objects in a runtime policy engine, and they are routinely conflated: 1. **The declared destination surface** — the set of hosts the tool *claims* it can reach, typically a regex or glob in the tool manifest, evaluated against the raw argument string. 2. **The effective destination** — the host the process will actually connect to after the tool's own argument parsing, normalization, template expansion, and any redirect-following has occurred. The allowlist is almost always written against (1) and enforced against (2), or vice versa. The boundary failure is the gap between them. Argument schemas are the mechanism that *appears* to close the gap — a schema constrains the argument's shape, and a reviewer assumes a constrained shape implies a constrained destination. It does not. A schema constrains syntax; egress depends on semantics, and semantics are applied by the tool's transport layer, not by the schema validator. The non-obvious invariant is this: **for any tool whose destination is argument-derived, allowlist enforcement is only sound if the allowlist predicate is evaluated on the same normalized form that the transport layer will resolve.** Any normalization that occurs between schema validation and connection establishment is an unmonitored channel. ## What Was Observed A tool `fetch_resource` was registered with an argument schema declaring `url` as `type: string, format: uri`. The egress allowlist permitted `^https://api\.internal\.example\.com/`. Enforcement was placed at the schema-validation layer: the validator parsed the argument, extracted the host, and matched it against the allowlist regex before dispatch. Three invocation classes passed enforcement and reached hosts outside the allowlist: - **Userinfo smuggling.** `https://api.internal.example.com@169.254.169.254/latest/meta-data/` — the validator's URI parser surfaced the *authority* as `api.internal.example.com@169.254.169.254`, and the regex, being unanchored at the `@`, matched the prefix. The HTTP client, correctly per RFC 3986, treated `api.internal.example.com` as userinfo and connected to `169.254.169.254`. - **Redirect divergence.** `https://api.internal.example.com/r/abc` returned `302 Location: https://attacker.example/`. The allowlist was checked once, pre-dispatch. The client followed the redirect. No re-check occurred. - **Template expansion.** A separate `render_and_fetch` tool accepted `endpoint` as an enum-constrained string; the enum values were expanded against a per-tenant base URL supplied in a *different* argument not covered by the schema's `additionalProperties: false`. The effective host was tenant-controlled. In all three cases the schema was satisfied, the allowlist matched, and the connection went somewhere the policy author did not intend. The failures were not schema violations. They were schema *satisfactions* that the allowlist predicate misread. ## Condition of Validity The allowlist-plus-schema model is sound only under the conjunction of: - **Single normalization point.** The string matched against the allowlist is byte-identical to the string the transport resolves. This requires the enforcement layer to *own* the normalization, not delegate it. In practice this means: parse once, canonically, with the same library the client uses, and pin that library version. - **No indirection after check.** No redirects, no DNS rebinding windows, no proxy re-resolution, no template expansion, no environment-variable substitution between the check and the `connect()`. If any indirection exists, the check must be re-applied at the point of connection, not before it. - **Full argument coverage.** Every argument that can influence the destination is inside the schema's constrained surface. `additionalProperties: false` is necessary but not sufficient; arguments consumed by the tool's transport but not declared in the schema are invisible to the validator. - **Canonical host comparison.** The allowlist compares the *host component* after canonicalization (lowercasing, IDNA/punycode, trailing-dot stripping, IPv4/IPv6 literal normalization), not a substring of the raw URI. If any of these fails, the model degrades from "allowlist" to "advisory prefix match," which is a different and much weaker guarantee. ## Decision Boundary The practitioner must choose between two enforcement architectures, and the choice is forced by whether the tool's destination is argument-derived: - **If the destination is fixed at registration:** enforce at the manifest. The allowlist is a static property; schema validation is sufficient. This is the cheap path and it is correct. - **If the destination is argument-derived:** schema validation is *not* an enforcement point. Enforcement must move to a **connection-level egress proxy** that receives the fully-resolved destination, or to a **post-normalization hook** inside the transport that re-evaluates the allowlist on the canonical host immediately before `connect()`. The schema's role shrinks to input hygiene — it prevents malformed arguments, not unauthorized destinations. The boundary is crossed the moment a tool accepts a URL, host, IP, bucket, or any string that a downstream library will interpret as a network locator. At that moment, the schema is a *parser*, and parsers are not policy. Do not let a `format: uri` annotation stand in for an allowlist check; the annotation validates that the string *is* a URI, which is precisely the property an attacker needs. A secondary boundary: when redirects are permitted, the allowlist must be applied per-hop, and the hop count must be bounded. A single pre-dispatch check with `follow_redirects=True` is equivalent to no check at all. ## Operational Effect Enforcement relocated to the connection layer changes the failure mode from *silent egress* to *loud refusal at the socket*, which is the operationally correct trade: a blocked legitimate request is a visible incident; a permitted illegitimate request is a silent one. It also means the schema can be relaxed without security consequence, and tightened for correctness independently — the two concerns decouple. The cost is that the egress proxy becomes a critical-path dependency and must itself be held to the single-normalization-point invariant. A proxy that re-parses the URL with a different library than the client reintroduces the gap it was meant to close. Pin the parser, log the canonical host on both sides of the check, and alert on any divergence between the host the policy engine saw and the host the transport connected to — that divergence is the only reliable signal that the invariant has been violated. ```python import ipaddress import urllib.parse from dataclasses import dataclass class EgressInvariantViolation(RuntimeError): """Raised when the host checked against the allowlist differs from the host the transport will connect to.""" @dataclass(frozen=True) class EgressDecision: canonical_host: str allowed: bool ALLOWED_HOSTS = frozenset({"api.internal.example.com"}) METADATA_PREFIXES = ("169.254.", "127.", "10.", "192.168.", "::1") def canonicalize_host(raw_url: str) -> str: parts = urllib.parse.urlsplit(raw_url) host = parts.hostname # strips userinfo, lowercases, handles brackets if host is None: raise EgressInvariantViolation(f"no host component in {raw_url!r}") host = host.rstrip(".").lower() if host.startswith("[") and host.endswith("]"): host = host[1:-1] return host def is_metadata_or_private(host: str) -> bool: try: addr = ipaddress.ip_address(host) except ValueError: return False return addr.is_private or addr.is_loopback or addr.is_link_local def enforce_egress(raw_url: str, *, transport_host: str | None = None) -> EgressDecision: """ Enforce at the connection boundary, not at schema validation. `transport_host` is the host the client library actually resolved; if it is supplied and differs from our canonical parse, the invariant has been broken somewhere and we refuse rather than guess. """ canonical = canonicalize_host(raw_url) if transport_host is not None and transport_host.rstrip(".").lower() != canonical: raise EgressInvariantViolation( f"normalization divergence: policy saw {canonical!r}, transport will connect to {transport_host!r}" ) if is_metadata_or_private(canonical): raise EgressInvariantViolation(f"refusing private/metadata destination {canonical!r}") if canonical not in ALLOWED_HOSTS: return EgressDecision(canonical_host=canonical, allowed=False) return EgressDecision(canonical_host=canonical, allowed=True) def fetch_resource(url: str, *, follow_redirects: bool = False) -> EgressDecision: decision = enforce_egress(url) if not decision.allowed: raise EgressInvariantViolation(f"egress denied for {decision.canonical_host!r}") if follow_redirects: # Redirects must be re-checked per hop; a single pre-dispatch check is unsound. raise EgressInvariantViolation("redirect-following requires per-hop egress re-evaluation") return decision ```