Egress Firewall Pattern
Default-deny outbound network access, configurable per workspace, evaluable at every call site.
Egress Firewall Pattern
Default-deny outbound network access, configurable per workspace, evaluable at every call site.
TL;DR (human)
Every outbound HTTP call routes through one shim. The shim consults firewall.evaluate(url, ctx). Allowlist comes from workspace config + admin override. Denials are audit-logged. Override flows exist for legitimate one-off needs (recorded + time-boxed).
For agents
Why allowlist
Blocklist failure modes:
- New attacker-controlled hosts appear constantly; you cannot enumerate them.
- One missed entry = an exfiltration path.
- Maintenance burden grows linearly with the threat landscape.
Allowlist failure mode is recoverable: a missing entry surfaces as a denied call, which a user can request an override for. Friction trades for safety.
The shim
One outbound call surface across the entire codebase:
// in a shared module
export async function safeFetch(url: string, init: RequestInit, ctx: CallContext) {
const decision = await firewall.evaluate(url, ctx);
if (decision.allow === false) {
auditLog.append({ action: "egress.denied", target: { url }, ctx, outcome: "failure", metadata: { reason: decision.reason } });
throw new SecurityError("SECURITY_EGRESS_DENIED", "Egress to this host is not allowed", {
hint: `Add ${decision.host} to workspace egress.allow, or use an admin override.`,
});
}
return fetch(url, init);
}Lint enforces that bare fetch is forbidden in shipped code; everything uses safeFetch.
The decision
firewall.evaluate(url, ctx) consults:
- Workspace allowlist — list of host patterns the workspace admin configured.
- Built-in allowlist — hosts the product itself needs (e.g. license server, telemetry endpoint). Compiled-in, not user-editable.
- Active override — temporary, time-boxed, audit-logged allow grants from an admin.
Returns:
{
allow: boolean;
reason: "workspace-allow" | "builtin-allow" | "override" | "not-allowlisted" | "explicit-deny";
host: string;
}Allowlist shape
Host patterns, not full URLs:
api.openai.com
*.googleapis.com
api.github.com
hooks.slack.comPatterns:
- Exact host: matches that hostname.
*.domain.tld: matches one-level subdomain.**.domain.tld: matches any depth subdomain (use sparingly).
Avoid full-URL allowlist (path-level allow). Attackers control redirects; an allowed api.example.com/safe redirects to api.example.com/exfiltrate. Match the host, not the path.
Override flow
Legitimate one-off needs (debugging, a one-time data sync):
- Admin requests an override:
firewall.override.request({ host, reason, expiresAt }). - The request itself is audit-logged.
- While active:
firewall.evaluatereturnsallow: true, reason: "override"for that host. - The override expires at
expiresAt; or admin revokes early viafirewall.override.revoke. - Override usage is audit-logged per call.
Overrides are visible in an admin UI alongside the audit trail.
Connection-bound egress
Some egress is bound to a connector (OAuth integration with GitHub, Slack, etc.). The connection itself implies the allowlisted host:
- When a workspace connects to GitHub,
api.github.comis implicitly allowed for that workspace. - When the connection is removed, the implicit allowance disappears (unless explicitly allowlisted elsewhere).
The connection's vault credentials and the firewall allowance are tied — disconnecting cleanly removes both.
Denial messaging
A denied call surfaces to the agent / user with:
- Code:
SECURITY_EGRESS_DENIED. - Hint: how to fix ("Add to workspace allow", "Request admin override").
- The host that was denied (so the user can decide).
Never silently drop. The agent must know why its fetch failed.
Performance
The firewall check is on every outbound call. Cache aggressively:
- Decision per
(workspaceId, host)cached in-memory with short TTL (seconds). - Override expiry invalidates the cache entry.
- Allowlist edits invalidate the cache for that workspace.
A check should add < 1 ms in steady state.
Sandbox interplay
Code-execution sandboxes (agent-run shell, agent-run code) have their own network policies — typically no network access by default, with optional egress through the firewall. The sandbox layer enforces; the firewall is consulted on a per-call basis when the sandbox does allow network.
Common failure modes
- Bare
fetchin shipped code. Bypasses the shim. → Lint blocks; CI gate catches. - Allowlist by full URL with paths. Redirect-bypass. → Match host only.
- Override with no expiry. Permanent allow accumulates. →
expiresAtrequired. - Allowlist edit not audit-logged. Admins silently widen surface. → Mutation is an audited event.
- No connector ↔ allowlist linkage. Disconnecting a connector leaves stale allowance. → Connection lifecycle drives implicit allowlist.
See also
universal.md— Rule 3 (egress allowlist).audit-ledger-pattern.md— denial / override events log here.rbac-pattern.md—firewall:overrideis a sensitive capability.