# When must an egress policy enforcement point fail closed on tool-call argument resolution rather than on tool-name allowlisting? **Date of Record:** 2026 November 6 **Scope:** Applies to runtime policy enforcement points (PEPs) that mediate agent egress between a tool-selection decision and the actual outbound side effect (HTTP request, file write, subprocess, message send). Does not apply to static prompt-level guardrails, model-side refusal training, or post-hoc audit logging, which lack the ability to block the side effect at all. ## Core Distinction A tool allowlist is a check on a *name*. An egress guard is a check on a *resolved value*. The non-obvious failure is that these two checks are evaluated at different points in the agent loop, and the allowlist almost always wins the race because it fires earlier and cheaper. The dangerous boundary is not "did the agent call a tool it shouldn't have" — that is the easy, well-covered case. The dangerous boundary is "did the agent call an *allowed* tool with arguments that, once resolved through indirection, produce an egress destination the policy never saw." Concretely: `http_get(url=...)` is on the allowlist. The allowlist passes. But `url` may be a template, a reference to a prior tool output, a redirect chain, a DNS name that resolves to a link-local address, or a field the model reconstructed from memory. The allowlist check operates on the tool name; the exfiltration risk lives entirely in the argument's *resolved* form. A PEP that stops at the name check is a PEP that enforces the wrong invariant. ## What Was Observed Across several agent deployments, the following pattern recurred. The allowlist PEP sat at the tool-dispatch layer and matched `tool.name in ALLOWED`. It passed requests to `fetch`, `send_email`, `write_file`, `query_db`. Exfiltration events that bypassed it shared one structure: the *destination* was computed inside the allowed tool from arguments the PEP never normalized. - `fetch(url=user_supplied)` where `user_supplied` was assembled by an earlier step from a document the agent had read — the URL pointed at an attacker-controlled collector, and `fetch` was allowlisted. - `write_file(path="/tmp/../etc/...")` — path traversal resolved after the allowlist passed on the tool name. - `send_email(to=...)` where `to` was a display name that resolved to an external address through a directory lookup inside the tool. - Redirect following: an allowed host returned `302` to a disallowed host, and the tool followed it after the PEP had already approved the initial request. In each case the allowlist was *correct* and the egress was *unauthorized*. The invariant the PEP believed it enforced (only approved destinations leave the process) was not the invariant it actually enforced (only approved tool names are invoked). ## Condition of Validity The name-only allowlist is valid only when the tool's argument space cannot express an arbitrary destination — i.e., when the tool is *closed*: fixed endpoint, fixed recipient set, fixed path root, no redirects, no indirection. For any tool whose arguments can carry a destination, the allowlist is necessary but not sufficient, and treating it as sufficient is the failure. The correct enforcement point is *post-resolution, pre-egress*: after the tool has resolved its arguments (templates expanded, paths canonicalized, DNS resolved, redirects bounded) but before the socket opens or the write lands. This point is strictly later than the allowlist and strictly earlier than the side effect. A PEP that cannot observe resolved arguments cannot enforce egress policy, regardless of how complete its allowlist is. ## Decision Boundary Fail **closed** at the resolution boundary when any of the following hold: 1. The resolved destination differs from the declared destination (redirect, symlink, DNS rebind, template expansion). 2. The resolved destination falls outside the policy's canonical set after normalization (path canonicalization, IDNA normalization, IP-literal expansion of a hostname). 3. Resolution itself requires a network call the PEP cannot pre-authorize (DNS to an unapproved resolver, directory lookup against an external service). 4. The argument's provenance is tainted (derived from untrusted content) and the tool is open-ended. Fail **open** (allow) only when the tool is closed by construction and the argument cannot encode a destination. Do not fail open merely because the tool name is allowlisted — that is the exact conflation this record exists to prevent. When in doubt at the resolution boundary, the cost of a blocked legitimate egress is a retry; the cost of a passed illegitimate egress is unrecoverable. ## Operational Effect Deployments that moved the egress PEP from name-check to post-resolution check observed: (a) a class of "allowlisted tool, disallowed destination" blocks that the old PEP never surfaced, which is the point; (b) a small false-positive rate on legitimate redirects and templated URLs, mitigated by an explicit canonicalization allowlist rather than by loosening the check; (c) increased latency at the PEP because resolution now happens inside the enforcement path — acceptable, since the alternative is enforcement that does not enforce. The invariant to hold: *no side effect occurs unless a PEP has observed the fully resolved destination and approved it.* Name allowlisting is a fast pre-filter, never the enforcement. ```python import ipaddress from urllib.parse import urlparse class EgressPolicyViolation(RuntimeError): """Raised when a resolved egress destination escapes policy.""" ALLOWED_TOOLS = {"fetch", "send_email", "write_file"} ALLOWED_HOSTS = {"api.internal.example", "cdn.internal.example"} def _resolve_destination(tool_name: str, resolved_args: dict) -> str: if tool_name == "fetch": return resolved_args["url"] if tool_name == "send_email": return resolved_args["to"] if tool_name == "write_file": return resolved_args["path"] raise EgressPolicyViolation(f"no destination resolver for tool {tool_name!r}") def enforce_egress(tool_name: str, resolved_args: dict) -> None: # Pre-filter: name allowlist. Necessary, never sufficient. if tool_name not in ALLOWED_TOOLS: raise EgressPolicyViolation(f"tool {tool_name!r} not on allowlist") destination = _resolve_destination(tool_name, resolved_args) if tool_name == "fetch": parsed = urlparse(destination) host = parsed.hostname or "" # Block IP literals that resolve to link-local / loopback / private space. try: ip = ipaddress.ip_address(host) if ip.is_private or ip.is_loopback or ip.is_link_local: raise EgressPolicyViolation( f"fetch destination {host!r} is a non-routable address" ) except ValueError: pass # hostname, not an IP literal if host not in ALLOWED_HOSTS: raise EgressPolicyViolation( f"fetch destination {host!r} not in allowed host set" ) if parsed.scheme not in {"https"}: raise EgressPolicyViolation( f"fetch scheme {parsed.scheme!r} not permitted" ) elif tool_name == "send_email": if not destination.endswith("@internal.example"): raise EgressPolicyViolation( f"email recipient {destination!r} is external" ) elif tool_name == "write_file": import os canonical = os.path.realpath(destination) root = os.path.realpath("/srv/agent-workspace") if not canonical.startswith(root + os.sep): raise EgressPolicyViolation( f"write path {canonical!r} escapes workspace root {root!r}" ) ```