PlatformLoad & Egress
Node
Node Egress Contract
What one process does with its own socket: two allowlists, unconditional private-range denial, a DNS re-check, and two ceilings.
The verdict next door, the socket here
egress-allowlist and internal-network, the two guards that answer whether a governed action may reach a host. That answer is Kernel content: computed before dispatch, from the request alone, with no DNS lookup. This page is the other half of the split: what the process does with its own socket once a verdict has already allowed the action.A leaf crate with no kernel under it
crates/protocol/chio-egress-contract has no internal chio-* dependencies and forbids unsafe. Without the reqwest-egress feature it has no I/O dependencies at all: validation and non-DNS enforcement need only serde, thiserror, and url. It knows nothing about capabilities, receipts, or tool manifests. Its only question is whether a target URL, its DNS answers, its redirect chain, and its response size satisfy a declared HttpEgressContract.
That question is Node content by the filing test. crates/kernel/chio-kernel-core/src/evaluate.rs lists what a pure evaluation refuses, and tool dispatch to wrapped servers over async transport is on it. The socket dispatch opens belongs to one OS process, governed by that process’s own configuration. Run two nodes and you get two contracts, each enforced locally, with nothing shared between them.
A delegated child task can carry a signed statement about the egress it intends to take, naming an egressContractId and a deny-private-network constraint, but that artifact is checked against the caller’s own route metadata and never resolved to an HttpEgressContract here: Route Plans states the boundary from the other side.
The field is optional and the fallback is a refusal
Option<HttpEgressContract>, so a deployment can leave it unset. Absence is not permissive. The config field’s own doc comment in chio-mcp-remote states the obligation and the fallback in the same breath, and the fallback denies:/// Typed HTTP egress contract that gates outbound HTTP from the remote
/// MCP runtime (most prominently the OAuth introspection endpoint).
/// Production deployments must populate this; absence falls back to
/// substrate fail-closed at dispatch.
pub egress_contract: Option<HttpEgressContract>,What a contract is
pub struct HttpEgressContract {
pub tenant_egress_namespace: String,
pub allowed_schemes: BTreeSet<String>,
pub allowed_authority_set: BTreeSet<String>,
pub deny_loopback: bool,
pub deny_link_local: bool,
pub deny_ipv6_ula: bool,
pub max_redirect_chain: u8,
pub max_response_bytes: u64,
}validate() checks shape only: a non-empty trimmed namespace, non-empty scheme and authority sets, a nonzero byte ceiling, every scheme in {http, https}, and every authority already canonical. A scheme token outside those two is rejected as InvalidContract carrying invalid HTTP egress scheme "ftp"; expected "http" or "https", so no contract can authorize ws or a custom scheme. An authority entry is rejected for a trailing dot, an embedded /, ?, #, or @, any uppercase byte, a bare trailing colon, a zero-padded port, or an uncompressed IPv6 literal: [2001:0db8::1] must be written [2001:db8::1]. A zero redirect cap is legal and means no redirect is ever followed.
prepare() runs validate() once and returns an immutable PreparedHttpEgressContract snapshot. Mutating the original struct afterward does not reach the snapshot, which prepared_contract_keeps_validated_snapshot_after_raw_mutation pins from both directions: the prepared handle still enforces the original policy, and the mutated raw contract is refused when reused. The convenience methods on the raw type re-run prepare() on every call, so a hot dispatch path should hold the prepared handle.
Address-class denial is two-tier
Every host that parses as an IP literal, and every address a domain resolves to, is sorted into one of five classes by classify_ipv4_address or classify_ipv6_address. Three classes are gated on a contract flag. One is denied whatever the contract says.
| Class | Members | Denied when | Error |
|---|---|---|---|
Loopback | 127.0.0.0/8, ::1 | deny_loopback | LoopbackDenied |
LinkLocal | 169.254.0.0/16, fe80::/10 | deny_link_local | LinkLocalDenied |
Ipv6Ula | fc00::/7 | deny_ipv6_ula | Ipv6UlaDenied |
PrivateOrSpecialUse | RFC 1918 (10/8, 172.16/12, 192.168/16), 0/8, CGNAT 100.64/10, 192.0.0/24, the three TEST-NET blocks, benchmark 198.18/15, 240/4, broadcast, IPv4 multicast; IPv6 unspecified, multicast, and 2001:db8::/32 | Always | PrivateNetworkDenied |
Global | Everything else | Never by class | n/a |
The names localhost and localhost.localdomain are not classified; enforce_loopback_hostname matches them directly and raises the same LoopbackDenied under the same flag. IPv4-mapped IPv6 is decoded before classification, in both directions, so [::ffff:10.0.0.5] is reported as 10.0.0.5 and denied. That is the only embedding the classifier unwraps.
Two consequences are worth reading twice. First, an allow-listed authority does not buy a private address: private_ipv4_literal_fails_closed_even_when_declared allow-lists 10.0.0.5 and still gets PrivateNetworkDenied. Second, the cloud metadata endpoint 169.254.169.254 is link-local, not private, so it is denied by a flag rather than unconditionally. Every constructor in the tree sets deny_link_local: true, but a hand-written contract clearing the flag and allow-listing that authority would be admitted. Which flags come off where is set out below.
The order of refusals
enforce_url denies on the first violation, in this order. The redirect ceiling is checked before the URL is even parsed.
| # | Check | Error on failure |
|---|---|---|
| 1 | redirect_chain_len against max_redirect_chain | RedirectLimitExceeded { observed, max } |
| 2 | Parse the target | InvalidUrl |
| 3 | Username or password present in the URL | UserinfoDenied |
| 4 | Lowercased scheme against allowed_schemes | SchemeDenied { scheme } |
| 5 | Host present, then address-class check on the literal | MissingAuthority, or one of the four class denials |
| 6 | Normalized authority against allowed_authority_set | AuthorityDenied { authority } |
Step 5 is weaker than it looks for a domain-name host: nothing has resolved yet, so the only name check available is the literal loopback pair. Step 6 carries the load for names, as exact set membership over normalized authorities, not a glob and not a suffix match. api.example.com.evil.test is AuthorityDenied against an allowlist holding api.example.com. The one equivalence granted is the scheme’s default port, in both directions: https://api.example.com:443/v1 matches a no-port entry, and https://api.example.com/v1 matches an api.example.com:443 entry.
enforce_url_with_dns runs all six, then resolves domain-name hosts through ToSocketAddrs and applies the address-class check to every returned address. A resolver returning nothing is DnsResolutionFailed, not a pass; a URL with neither an explicit port nor a known default port is InvalidUrl before the lookup is attempted. This is the only path in lib.rs that protects a domain name pointed at a private address. enforce_url and enforce_attempt alone do not resolve.
Dispatch under the reqwest feature
crates/protocol/chio-egress-contract/src/reqwest_helper.rs:56-213at fe56570The client is not the caller’s. client_builder_with_contract builds a reqwest::Client with redirects disabled, proxying disabled, and DNS pinned to a contract-backed resolver, and the returned ContractClientBuilder exposes exactly one knob, .timeout(), so a caller who goes through it cannot re-enable redirect following or swap the resolver back out. A caller who hands send_with_contract a client of their own can, so the helper does not take it on trust: a response URL differing from the request URL returns an error naming the misconfiguration rather than accepting an unvalidated hop.
Each hop costs two DNS lookups for a domain-name host. The enforce_url_with_dns call at the top of the loop resolves through the synchronous std::net::ToSocketAddrs, on the async task, then discards the answers; the pinned resolver resolves the same name again for the connect, and only that second resolution is the one the socket uses.
Redirects are replayed by hand so each hop can be re-authorized. A cross-origin hop carrying a body or a non-idempotent method is refused with a message containing cross-origin redirect method/body denied, asserted for all five redirect statuses against a POST with a body, each test also asserting the forbidden target received no request. There is no dedicated error variant: the denial arrives as HttpEgressError::InvalidUrl, so telling it from a malformed URL means matching on the message. A 307 or 308 that cannot be cloned is an error, not a silent body-less replay. Where the hop is legal, headers are dropped by should_drop_redirect_header:
| Header | Dropped when |
|---|---|
Host | Always, on every hop. |
Authorization, Cookie, Proxy-Authorization | Cross-origin only. A same-origin hop keeps them. |
Content-Length, Content-Type | Only when the body goes: a 303, or a POST-to-GET rewrite on 301 or 302. A 307 or 308 replay keeps both. |
The byte ceiling is enforced twice: against Content-Length when the response declares one, then against a running counter checked after every chunk. The chunked case is pinned by a test that streams 4 MiB with no Content-Length under a 64 KiB ceiling and asserts the observed count landed under an eighth of the body. What it does buffer is everything up to the ceiling: ContractResponse holds a Vec<u8> and offers no streaming reader, so max_response_bytes is also the per-request memory bound. Read the 64 MiB ceilings below with that in mind.
Three resolvers, two of them pinned
Resolution and enforcement are implemented three times, because three HTTP clients are in the tree. The crate’s ARCHITECTURE.md claims two of them are deliberately kept in sync; the third lives in chio-a2a-adapter, is not covered by that claim, and omits the hostname re-check. Two of the three close the window between the check and the connect by making the resolver that answers the connect the same one that ran the address-class check. The standalone enforce_url_with_dns does not.
| Path | Resolves through | Extra check before lookup |
|---|---|---|
enforce_url_with_dns (default features) | Synchronous std::net::ToSocketAddrs | None. This is a policy check the caller runs before its own client connects |
ContractDnsResolver (reqwest-egress) | tokio::net::lookup_host | enforce_resolver_hostname: re-runs dispatchability validation and requires the hostname to match an allowed authority’s host |
A2aContractResolver (chio-a2a-adapter, ureq) | ToSocketAddrs inside a ureq::Resolver | None. Denials are mapped to PermissionDenied IO errors |
chio-link uses enforce_url_with_dns as a config-time admission check and then dispatches through a pinned client. chio-settle goes further: its per-request validate_rpc_egress_contract calls plain enforce_url, on the stated grounds that a config-time lookup would be redundant, offline fragile, and open to TOCTOU drift given the pinned resolver runs anyway. The OpenAPI-to-MCP bridge has no pinned resolver to lean on: it is deliberately transport-agnostic, so invoke_tool validates pre-flight, hands the URL to a caller-supplied dispatcher closure, then refuses any 3xx status and checks the dispatcher’s self-reported observed_body_bytes. If you supply that dispatcher, the socket is yours and so is the rebinding risk.
Where the resolver is pinned, send_with_contract_refuses_loopback_resolving_host_before_connect points an allow-listed hostname at a live loopback server, keeps deny_loopback on, asserts LoopbackDenied, and then asserts the server observed no connection.
What operators actually set
There is no chio.yaml block for an egress contract. Most contracts are derived in code from the endpoint the operator already named, with the ceilings chosen by the caller. The recurring shape is an allowlist holding the configured authorities and nothing else, deny_link_local and deny_ipv6_ula hard-coded true, and a scheme set holding only the configured URL’s own scheme.
Two crates do take the contract from an operator file
HttpEgressContract derives Serialize and Deserialize, and two config structs make it a required field under deny_unknown_fields: PriceOracleConfig in chio-link and SettlementChainConfig in chio-settle. Feed either crate a serialized config and you write every flag and both ceilings by hand. Both also ship *_default constructors that derive the contract; prefer those.| Built by | Namespace | Authorities | Redirects | Byte ceiling |
|---|---|---|---|---|
default_upstream_egress_contract (chio-api-protect) | chio-api-protect-upstream | The one --upstream URL | 4 | 64 MiB |
contract_for_url (chio-external-guards) | Per-adapter constant | The one guard endpoint | 4 | 1 MiB |
registry_egress_contract_for_url (chio-guard-registry) | chio-guard-registry | The one registry authority | 3 | 16 MiB |
remote_mcp_auth_egress_contract (chio-cli) | remote-mcp-auth:<server_id> | Every configured discovery, introspection, and JWKS URL, plus the issuer only when a provider profile or discovery URL is set. Returns None when none of them are configured, which is where the remote MCP Option becomes empty in practice. | 3 | 1 MiB |
SIEM alert and export paths (chio-siem) | siem:<prefix>:<authority> | The one exporter or webhook authority | 3 | 1 MiB |
build_default_egress_contract (chio-link) | chio-link | The Pyth Hermes URL plus every chain RPC endpoint | 1 | 4 MiB |
Public settlement RPC (chio-proof-room, chio-cli) | proof.public-settlement.rpc | The one RPC endpoint | 0 | 1 MiB |
That is a sample; the devnet chain-RPC constructors in chio-anchor and chio-settle and the strict-HTTPS anchor contract in chio-control-plane follow the same pattern with their own numbers. Only chio-link carries a poison-pill fallback: fail_closed_default_egress_contract returns an allowlist of invalid.localhost with a zero redirect cap and a one-byte ceiling when no authority could be derived.
Four different ways deny_loopback comes off
deny_link_local is set true by every constructor in the tree, and only two can lower it: the CLI auth contract and the SIEM alert contract, each only when a configured endpoint is itself link-local. deny_loopback is looser, and the four rules are not equivalent.
| Rule | Where | What actually decides |
|---|---|---|
| The configured endpoint is loopback | chio-api-protect, chio-external-guards, chio-cli, chio-siem | The host in the URL the operator supplied. |
| The constructor insists on loopback first | Devnet chain RPC in chio-anchor, chio-settle | A non-loopback RPC URL is refused before the contract is built, then deny_loopback: false is set for the target it just insisted on. |
| The operator allow-listed a plain-HTTP registry | chio-guard-registry | deny_loopback: !http_allowlisted, where http_allowlisted is membership in allow_http_registries. A scheme decision, not an address decision: any host on that list loses loopback denial whether or not it is loopback. |
| A debug build reads an environment variable | Public-settlement RPC in chio-proof-room, chio-cli | cfg!(not(debug_assertions)) || !optional_bool_from_env("CHIO_TEST_PUBLIC_SETTLEMENT_ALLOW_LOOPBACK_RPC"). A release build pins it true and ignores the variable; a debug build honours it. |
validate_dispatchable_with_pinned_dns is named as the build-time gate for the pinned-resolver path, confirming every allowed authority is parseable by that resolver. Two things temper it. On the raw type it runs validate() and then repeats a per-authority check validate() already performs, so as the code stands it is equivalent to validate(). And only six crates call it (chio-api-protect, chio-external-guards, chio-anchor, chio-settle, chio-link, chio-control-plane); chio-guard-registry, chio-cli, and chio-siem are reqwest-backed and call plain validate().
permissive_for_tests reaches further than its name suggests
HttpEgressContract::permissive_for_tests returns a contract with all three denial flags off, a redirect cap of 4 and a 64 MiB ceiling, so a wiremock or local server on 127.0.0.1 is reachable. It is deliberately not #[cfg(test)]-gated, because dependent crates need it, and its doc comment states that production code must not call it. Nothing in the type system enforces that. Worse for a reviewer: all six SIEM exporters expose a public, equally ungated new_plaintext_for_tests or new_with_base_url_for_tests that silently substitutes permissive_for_tests when the config carries no contract, where the ordinary new would have refused. Grep for _for_tests in your own wiring, not just for the contract type.Where the missing contract is caught
The crate ships a fail-closed entry point, HttpEgressContract::enforce_required, which turns a None contract into MissingContract. No production caller uses it: each holds its own Option and denies with a message in its own error type. The behaviour is uniform, the wording is not, and the denial lands at different points in the lifecycle.
| Caller | Caught at | Result |
|---|---|---|
Remote MCP introspection auth mode (build_remote_auth_mode) | Startup, while resolving the auth mode | --auth-introspection-url requires an HttpEgressContract; substrate fails closed. The process does not start. |
OIDC discovery and JWKS fetch (fetch_identity_provider_json) | Before the client is built | <field> `<url>` requires an HttpEgressContract; substrate fails closed |
Token introspection at request time (IntrospectionBearerVerifier) | Per authenticated request | HTTP 502 with token introspection requires an HttpEgressContract |
| A2A outbound dispatch and response reads | Per call, before the agent is built | AdapterError::InvalidUrl. The ureq agent also drops its contract-backed resolver when the contract is absent, which is why the earlier refusal has to hold. |
OpenAPI-to-MCP bridge (enforce_dispatch_contract) | Per dispatch, pre-flight | OpenAPI bridge dispatcher requires an HttpEgressContract. A dispatcher that reports no observed_body_bytes is refused separately. |
| SIEM exporters (Datadog, Splunk, Elastic, Sumo Logic, OCSF, webhook) | Construction, then again on every export | ExportError::HttpError. The exporter is never created, and the export path re-checks rather than trusting construction. The OCSF exporter only requires a contract when its endpoint is non-empty. |
Three callers avoid the question by typing the field as a plain HttpEgressContract rather than an Option: chio-api-protect’s proxy state, which builds one during proxy construction from the upstream URL, plus PriceOracleConfig and SettlementChainConfig. Read that the other way round when you build a new adapter: the option exists so a contract can be threaded from configuration, not so dispatch can proceed without one.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | A private or special-use address is denied even when its authority is allow-listed, and the refusal reports the decoded IPv4 form for a mapped IPv6 literal. | enforce_ip_address_class; tests private_ipv4_literal_fails_closed_even_when_declared, ipv4_mapped_private_literal_fails_closed_even_when_declared |
| Shipped | No contract can authorize a non-HTTP scheme, a URL carrying userinfo, or a non-canonical authority entry. | validate_scheme_token, validate_authority_token, the userinfo branch of enforce_url |
| Proved by test | A hostname that resolves to loopback is refused before any socket opens: the loopback server records no connection. | send_with_contract_refuses_loopback_resolving_host_before_connect |
| Proved by test | A chunked response with no Content-Length is aborted on a bounded prefix rather than buffered whole. | send_with_contract_aborts_oversized_chunked_body_mid_stream |
| Proved by test | The real chio api protect proxy answers 502 with upstream error: link-local egress target denied when an upstream redirects to 169.254.169.254. A sibling test redirects to a second loopback server and asserts it received no request; that one is refused by the authority allowlist, not by loopback class, because the test’s own upstream is loopback and so deny_loopback is off. | crates/tooling/chio-conformance/tests/ssrf_external_guard_api_protect_dispatch.rs |
| Proved by test | Five deny variants are driven against the production enforcement functions rather than a mock: LoopbackDenied, LinkLocalDenied, Ipv6UlaDenied, RedirectLimitExceeded, ResponseTooLarge. PrivateNetworkDenied, the one denial no flag can lower, is not among them; it is covered by crate unit tests instead. | threats/ssrf_via_http_substrate.rs, plus ten ssrf_* negative-conformance files |
| Limit | Only IPv4-mapped IPv6 (::ffff:a.b.c.d) is decoded. A 6to4, Teredo, NAT64, or IPv4-compatible address embedding a private IPv4 classifies as Global. The authority allowlist is the primary control here, but a DNS answer for an allow-listed hostname is not filtered by it. | classify_ipv6_address, is_ipv6_private_or_special_use |
| Limit | The contract holds no hostname blocklist. Names like metadata.google.internal are refused because they are not in the authority allowlist, not because they are recognized. Name-based blocking is the internal-network guard’s job. | enforce_loopback_hostname matches two literals and nothing else |
| Limit | Contracts are per-caller, not per-node. Nothing aggregates them, and nothing stops one process from holding six contracts with six different ceilings, which is what it does today. | The constructor table above |
| Limit | The reqwest helper buffers the whole response body in memory up to the ceiling and returns it as one Vec<u8>. There is no streaming reader, so max_response_bytes is a memory budget as much as a policy bound. | collect_capped_response, ContractResponse |
| Limit | The OpenAPI-to-MCP bridge never opens the socket itself. It validates pre-flight and checks the dispatcher’s reported status and byte count afterwards, so the check-to-connect window stays open for whatever dispatcher the embedder supplies. | OpenApiMcpBridge::invoke_tool, enforce_no_redirect_response, enforce_bridged_response_body |
| Limit | A cross-origin body-preserving redirect denial has no error variant of its own. It arrives as InvalidUrl with a message, alongside parse failures and transport failures, which the in-tree tests match on as a string. | build_redirect_request, map_reqwest_error |
| Not wired | enforce_required is public and tested but has no production caller; every caller open-codes its own None denial. Match on behaviour, not on the message text. | Only in-tree call is missing_contract_fails_closed in chio-http-core/tests/http_egress_contract.rs |
| Schema only | Binding a contract into evidence. The agent-web OpenAPI subject schema requires an egress_contract_digest and validates it as 64 hex characters, but nothing in the tree derives that digest from a live contract, and the fixtures carry placeholder values. | crates/platform/chio-agent-web-interop/src/artifacts/openapi.rs |
| Unsupported | Non-HTTP egress. This is an HTTP boundary. A raw TCP connection, a database driver, or a DNS query opened by a wrapped tool server passes nowhere near it. | Crate scope: HttpEgressContract and the two HTTP schemes |
Next Steps
- Network Guards · the Kernel half: the allowlist and SSRF verdicts computed before dispatch, and why they cannot resolve DNS
- Remote MCP Edge · the startup order that derives the auth contract from the configured identity-provider URLs
- Guard Supply Chain · registry referrer and blob reads under their own contract, and the typed denial they raise
- Fail-Closed Semantics · the pattern this page is one instance of, stated once for the whole kernel
- Node Overview · the rest of what one process owns because the kernel refuses to