Skip to content

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.

EgressClient wraps a reqwest::Client and a SharedPolicy (Arc<dyn EgressPolicy>). The only way to send a request is through EgressRequestBuilder::send, which:

  1. Builds the fully-formed reqwest::Request (method, URL, headers, body).
  2. Calls policy.check(&request).await.
  3. If the policy returns EgressDecision::Allow, executes the request.
  4. If the policy returns EgressDecision::Deny { reason }, returns Err(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.

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):

ConstantValue
CONNECT_TIMEOUT30 s
READ_TIMEOUT300 s (between-bytes)
TOOL_REQUEST_TIMEOUT300 s (per-request wall clock)

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));

The AgentEgressPolicy uses the classifier in wcore-agent/src/egress/classify.rs. Given the method and URL it returns one of four verdicts.

VerdictMeaning
AllowDestination 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 shortcutLoopback, 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, or PATCH request to a non-allowlisted external host. Any body is treated as a potential data channel.
  • A GET or HEAD where the combined path + query length exceeds 96 characters (GET_DATA_LEN_THRESHOLD = 96).
  • A GET or HEAD where 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.

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"]

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 on
egress_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 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).

~/.wayland-core/config.toml
[security]
enabled = false
Terminal window
# 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.

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.