Network Egress Gate
All outbound HTTP traffic in Wayland Core flows through a single client type: EgressClient from wcore-egress. A Clippy disallowed-methods lint bans reqwest::Client::new and reqwest::Client::builder everywhere else in the workspace, so a missed migration site fails the lint and the build. It is structurally impossible to add an off-gate network call.
The gate is on by default. No configuration is required to activate it; opting out requires an explicit config change plus a CLI flag.
The chokepoint
Section titled “The chokepoint”EgressClient wraps a reqwest::Client and a SharedPolicy (Arc<dyn EgressPolicy>). The only way to send a request is through EgressRequestBuilder::send, which:
- Builds the fully-formed
reqwest::Request(method, URL, headers, body). - Calls
policy.check(&request).await. - If the policy returns
EgressDecision::Allow, executes the request. - If the policy returns
EgressDecision::Deny { reason }, returnsErr(EgressError::Denied(reason))without opening a TCP connection. The network layer is never reached.
This is the single property the gate rests on: a Deny decision fires before any bytes leave the process.
Client presets
Section titled “Client presets”Three named constructors cover common usage patterns.
// Streaming LLM providers: 30s connect, 300s between-bytes read, no redirects,// no per-request wall-clock cap (a total timeout would truncate long generations).let client = EgressClient::streaming();
// Non-streaming tool calls (REST/GraphQL): same connect + read timeouts,// plus a 300s request-level cap that catches slow-drip servers.let client = EgressClient::tool();
// Bare client with no timeouts. Prefer the presets for real work.let client = EgressClient::new();Redirect following is disabled on streaming() and tool() by setting reqwest::redirect::Policy::none(). A 302 response is surfaced to the caller rather than followed; this closes the credential re-attach exfiltration vector where a redirect target could receive an Authorization header the original host never should have seen.
Timeout constants (client.rs):
| Constant | Value |
|---|---|
CONNECT_TIMEOUT | 30 s |
READ_TIMEOUT | 300 s (between-bytes) |
TOOL_REQUEST_TIMEOUT | 300 s (per-request wall clock) |
Policy model
Section titled “Policy model”Every EgressClient carries a SharedPolicy. Clients built without an explicit policy carry GlobalDefaultPolicy, a lazy proxy that reads from a process-global OnceLock<SharedPolicy> at send time. This means a client constructed before install_global_policy is called still honors the policy once it is installed.
// Install once, early in main(). Returns Err if a policy is already installed;// the one-shot contract prevents a plugin from swapping the gate out mid-session.wcore_egress::install_global_policy(Arc::new(my_policy))?;Until a policy is installed, the global falls back to AllowAllPolicy. In normal CLI operation the engine installs the real AgentEgressPolicy before any tool traffic starts.
A client can carry its own policy independently of the global:
let isolated = EgressClient::tool().with_policy(Arc::new(MyPolicy));Four-tier classifier
Section titled “Four-tier classifier”The AgentEgressPolicy uses the classifier in wcore-agent/src/egress/classify.rs. Given the method and URL it returns one of four verdicts.
| Verdict | Meaning |
|---|---|
Allow | Destination is allowlisted or local. Proceeds silently. |
Ask { host, registrable, reason } | New, non-exfil-shaped destination. Operator consent required (ask-with-memory). |
Exfil { host, shared_platform, reason } | Exfiltration-class. Always gated; never auto-allowed; never persisted as an apex allow. |
| Local shortcut | Loopback, RFC 1918, CGNAT (100.64/10), link-local, IPv6 ULA, and localhost are always Allow. These are not exfiltration targets. |
Exfil-class triggers:
- A
POST,PUT, orPATCHrequest to a non-allowlisted external host. Any body is treated as a potential data channel. - A
GETorHEADwhere the combined path + query length exceeds 96 characters (GET_DATA_LEN_THRESHOLD = 96). - A
GETorHEADwhere a single base64/hex-ish token of 24 or more characters appears anywhere in the path or query (HIGH_ENTROPY_TOKEN_LEN = 24). - Any request to a shared-platform host that has not been exact-host-allowed (see below).
An Ask verdict goes to the operator’s consent doorbell. The policy awaits the response before resolving. For Exfil verdicts the operator is always prompted; the verdict never auto-allows and a persistent “always” decision is scoped to the exact host, not the registrable apex.
Shared-platform hosts
Section titled “Shared-platform hosts”Certain hosting providers place many mutually-untrusted tenants under one registrable domain. Allowlisting the apex domain would open every tenant on that platform, not just yours. These hosts are classified as “shared-platform” and can never be apex-allowlisted; only an exact full host may be allowed.
The SHARED_PLATFORM_SUFFIXES list in classify.rs includes:
- Code and paste:
raw.githubusercontent.com,gist.github.com,githubusercontent.com,github.io,gitlab.io,pastebin.com,paste.ee,hastebin.com,termbin.com - Object storage:
amazonaws.com,blob.core.windows.net,storage.googleapis.com,r2.cloudflarestorage.com,digitaloceanspaces.com - Serverless / preview / tunnels:
workers.dev,vercel.app,netlify.app,pages.dev,ngrok.io,ngrok-free.app,trycloudflare.com,onrender.com,herokuapp.com,glitch.me,repl.co,replit.dev - Request bins and OOB canaries:
requestbin.com,webhook.site,burpcollaborator.net,interact.sh,canarytokens.com,pipedream.net,beeceptor.com,oast.fun,oast.live,oastify.com
If you try to add amazonaws.com to your allowlist, AllowList::allow_domain silently drops it. To reach a specific S3 or Bedrock endpoint you must add the exact host:
[security]egress_allow = ["bedrock-runtime.us-east-1.amazonaws.com"]Configuration
Section titled “Configuration”The [security] block in ~/.wayland-core/config.toml (or a project-local .wayland-core.toml) controls the gate.
[security]# enabled = true # default - omitting this key leaves the gate onegress_allow = [ "api.my-internal-tool.com", "myapp.workers.dev", # exact host for shared-platform]egress_allow entries are additive: global and project-local lists are concatenated at config-merge time. Registrable domain entries (e.g. "example.com") cover all subdomains. Exact host entries (e.g. "myapp.workers.dev") match only that host.
The auto-derived first-party allowlist (approximately 30 LLM provider, tool backend, and package-registry domains) is always seeded before operator additions, so standard provider endpoints work without any manual configuration.
Disabling the gate
Section titled “Disabling the gate”Disabling requires two explicit steps. A config-only change is intentional: an environment variable override would be a supply-chain attack vector (a compromised dependency setting an env var to strip egress enforcement).
[security]enabled = false# The CLI flag must also be present at the same invocation.wayland-core --i-accept-exfil-risk "your prompt"When disabled, the engine installs an allow-all policy and logs a tracing::warn! at startup. Both the config change and the CLI flag must be present simultaneously; the flag alone does not disable the gate, and the config change alone does not take effect.
Structural enforcement: the Clippy ban
Section titled “Structural enforcement: the Clippy ban”The clippy.toml at the workspace root lists reqwest::Client::new and reqwest::Client::builder under disallowed-methods. Any crate that adds a raw reqwest::Client construction outside wcore-egress/src/client.rs fails the lint check in CI. The EgressClientBuilder::new() constructor carries an #[allow(clippy::disallowed_methods)] annotation as the single sanctioned exception, and that annotation is not exported to callers.
This means the policy is enforced at the type level rather than by code review discipline alone. A new tool or transport that needs an HTTP client must depend on wcore-egress and use EgressClient; there is no path around the gate.