Egress
Per-workload FQDN network control — policies, presets, live grants, and the audit trail.
Network access is the platform's headline capability. A workload's L3 network is default-deny with exactly one route out: the platform's CONNECT proxy. Which hosts it may reach is a policy you set per job, per sandbox, per app — and can change on a running sandbox.
NetworkPolicy has no concept of a domain name; this does.
The model
- L3 default-deny. No DNS, no CIDR rule to the internet, no UDP, no QUIC, no IPv6 allow. One additive rule: TCP to the proxy.
- The proxy resolves every name itself. The workload has no resolver that reaches the network, so DNS tunnelling isn't filtered — it's structurally impossible.
- The policy is control-plane state. The proxy is fed live from the api, so a change lands in milliseconds without restarting anything.
- Every decision is audited — allow and deny alike.
HTTP_PROXY is set in the workload for compliant clients, but it is not the boundary. A hostile workload that unsets it simply has no network. Clients that don't speak proxy protocols are caught anyway: the agent answers 127.0.0.1:53 for names the policy already allows and takes the connection through the same authorised path.
Modes
enum Mode {
MODE_NONE = 1, // literal isolation: no proxy route, DNS blackholed
MODE_ALLOWLIST = 2, // proxy; only hosts in `allow` (+ presets)
MODE_PERMISSIVE = 3, // proxy; any public host — still SSRF-guarded and audited
}MODE_NONE is the default for jobs and the usual steady state for sandboxes. MODE_PERMISSIVE is for install windows, and is best used as a short-lived grant rather than a resting state.
Host rules
egress: {
mode: "allowlist",
presets: ["pypi"],
allow: [
"my-bucket.s3.example.com", // a bare string is the host, tunnelled on :443
{
host: "*.githubusercontent.com",
mode: "terminate",
pathPrefixes: ["/xeonr/"],
methods: ["GET"],
maxResponseBytes: 50_000_000,
},
],
}For the common shapes there are builders — egress.none(), egress.allowlist("registry.npmjs.org"), egress.presets("npm", "go-proxy"), egress.permissive(). An allow list or a preset without an explicit mode means allowlist, since that is the only mode in which either has any effect.
host_pattern is exact or leftmost-wildcard. ports defaults to [443].
Rule modes
| Mode | What the proxy does |
|---|---|
RULE_MODE_TUNNEL (default) | Plain CONNECT. Host and port only; contents opaque. Cheap, and safe with cert-pinning clients |
RULE_MODE_TERMINATE | Terminates TLS with a per-job ephemeral CA, enabling path_prefixes, methods, max_response_bytes and full-URL + response-hash audit |
RULE_MODE_TUNNEL_SNI_PINNED | Escape hatch for clients that reject any injected CA (some apt, go, Electron builds). SNI and host pinned; the audit event is flagged degraded |
Wildcards belong in TERMINATE. In tunnel mode the path is invisible, so a broad wildcard host (*.amazonaws.com, a CDN) is an exfiltration channel. Pin buckets and registries to exact hosts in tunnel mode; when a rule genuinely needs a wildcard, terminate it so the path is scoped and audited.
The MITM CA is generated per job, its private key exists only in the proxy's memory and is destroyed when the job ends, and the agent writes the public cert into the pod's trust stores at container start — never baked into an image. The proxy re-originates TLS upstream against the real root store, so termination inspects traffic without weakening upstream authenticity.
Enforcement per connection
- The
CONNECThost must match the allowlist. - The ClientHello's SNI must equal the CONNECT host — this kills domain fronting. An absent, unparseable or encrypted (ECH) SNI fails closed.
- The proxy resolves the name itself and dials that exact IP, so there's no rebind window; RFC1918, link-local, IMDS (
169.254.169.254), loopback and ULA answers are rejected. CONNECTto an IP literal is refused. TCP only.- Plain HTTP is off unless the policy or the rule sets
allow_plain_http, and Host-header allowlisting then applies.
Presets
Platform-maintained expansions, referenced by name. Their expansion is versioned into the policy hash, so a preset change can't silently alter an existing binding.
| Preset | Expands to |
|---|---|
npm | registry.npmjs.org |
pypi | pypi.org, files.pythonhosted.org |
go-proxy | proxy.golang.org, sum.golang.org |
apt-debian | deb.debian.org, security.debian.org |
apt-ubuntu | archive.ubuntu.com, security.ubuntu.com, ports.ubuntu.com |
github-release | github.com, objects.githubusercontent.com, *.githubusercontent.com |
crates | crates.io, static.crates.io, index.crates.io |
maven | repo.maven.apache.org, repo1.maven.org, *.gradle.org |
nuget | api.nuget.org, *.nuget.org |
rubygems | rubygems.org, index.rubygems.org |
deno | deno.land, jsr.io, *.jsr.io |
open-vsx | open-vsx.org |
gitlab-prod | git.prod.wtf |
Named policies
Reusable policies live in EgressPolicyService; jobs and sandboxes reference them by id through EgressRef, or inline a policy directly.
import { EgressPolicy_Mode } from "@xeonr/containers/proto";
const { policy } = await cp.raw.egress.createEgressPolicy({
namespace: "my-app",
name: "s3-read",
policy: {
mode: EgressPolicy_Mode.ALLOWLIST,
allow: [{ hostPattern: "my-bucket.s3.example.com", ports: [443] }],
},
});
await cp.jobs.run({ /* … */, egress: policy.egressPolicyId });Managing named policies is a raw call, so the message is the protocol's own — @xeonr/containers/proto re-exports the generated types for exactly this. Referencing one afterwards is not: pass its id as egress anywhere a policy is accepted, since that field takes an id or an inline policy.
Grants
A grant is an additive layer over a sandbox's base policy: the egress in force is the base unioned with every active grant. This is the permissive→strict toggle applied live, which is exactly what an install step needs.
await using grant = await sandbox.grantEgress(egress.presets("pypi"), {
ttl: "5m", // omit for until-revoked
reason: "pip install -r requirements.txt",
});
await sandbox.shell("pip install -r requirements.txt");
// revoked here, on the way out of the scopeHeld in an await using, the window closes with the scope. Without it you are remembering a grant id, and the failure mode of forgetting is a sandbox that keeps reaching the internet long after the install that needed it finished. grant.revoke() is there for callers that cannot use the scope form.
reason lands in the audit log. Each call yields a separately revocable grant; RevokeEgress(sandbox_id, grant_id) removes exactly that layer and leaves the rest. Expiry is computed rather than reaped, so a stalled janitor can never hold a window open past its TTL, and a MODE_NONE grant can cut a running sandbox off without killing the pod.
Active grants are readable on GetSandbox as egress_grants, each with its mode, the hosts it opened, its reason and expiry.
If you're driving grants from an AI agent, note the shape this is designed for: grants are policy-shaped and audited, so a coaxed agent widens access to a named, logged set of hosts rather than to the internet.
Internal targets
The proxy rejects private addresses for everything — that's the SSRF guard. When a namespace genuinely needs to reach an in-cluster service, an admin registers the destination on the namespace:
await cp.raw.namespace.updateNamespace({
namespace: "my-app",
internalTargets: [{ host: "api.xeonr-upl-prod.svc.cluster.local", port: 8080 }],
});The split matters: a policy is namespace-authored, so if "this host is internal" were a policy property, any namespace could name any service in the cluster. Registration is admin-only; a policy must still name the host in its allowlist, but the permission comes from this list, and the two are joined when the control plane compiles the policy.
Traffic still goes through the proxy — same allowlist match, same SNI check, same audit. Only the RFC1918/ULA rejection is relaxed: loopback, link-local (IMDS), CGNAT and multicast stay denied for internal targets too.
Audit
Every decision is an event, queryable per job, per sandbox, or across a namespace.
const { events } = await cp.raw.egress.listEgressEvents({
namespace: "my-app",
principalId: jobId,
});
for await (const ev of cp.raw.egress.streamEgressEvents({ namespace: "my-app" })) {
console.log(ev.event.decision, ev.event.host, ev.event.detail);
}cp.raw is the generated client for every RPC the SDK does not model, and it throws the same error taxonomy as the rest of the surface.
| Decision | Meaning |
|---|---|
ALLOWED | Permitted and connected |
DENIED_POLICY | Host or port not in the effective policy |
DENIED_PRIVATE_IP | Resolved to a private/loopback/link-local address (SSRF guard) |
DENIED_IP_LITERAL | CONNECT to an IP address rather than a name |
DENIED_SNI_MISMATCH | ClientHello SNI didn't match the CONNECT host, or was absent/encrypted |
UPSTREAM_UNREACHABLE | Policy allowed it, the network refused it — the destination was down, not blocked |
method, path and status are populated for TERMINATE rules. event_id is unique per decision, so a live stream and a history query merge exactly. detail carries the human-readable reason, which is usually what you want on a denial.
Debugging a blocked request
ListEgressEventsfor the job or sandbox. ADENIED_POLICYrow names the host that was refused — usually a CDN or a redirect target the allowlist missed.- No event at all? The traffic never reached the proxy. Check the policy isn't
MODE_NONE, and that the client isn't using UDP/QUIC (Go and curl HTTP/3 paths), which is never permitted. UPSTREAM_UNREACHABLEmeans your policy is fine and the destination isn't answering.- TLS errors under a
TERMINATErule usually mean a cert-pinning client. Switch that rule toRULE_MODE_TUNNEL_SNI_PINNEDand accept host-only scoping. - A workload that hangs with no events is often one that overrode
HTTP_PROXYor a trust-store variable — which is why the API rejects those names.