Egress Control
Layered egress security controls for restricting outbound network access.
Forge provides layered egress security controls that restrict which external domains an agent can access — at both build time and runtime.
Overview
Egress security operates at two levels:
- Build time — Generates allowlist artifacts and Kubernetes NetworkPolicy manifests for container-level enforcement
- Runtime — An in-process
EgressEnforcer(Gohttp.RoundTripper) validates every outbound HTTP request, and a localEgressProxyenforces the same rules on subprocess HTTP traffic (skill scripts,cli_execute)
The system resolves allowed domains from three sources:
- Explicit domains — Listed in
forge.yaml - Tool domains — Inferred from registered tools
- Capability bundles — Pre-defined domain sets for common services
Profiles
Profiles set the overall security posture. Defined in forge-core/security/types.go:
| Profile | Description | Default Mode |
|---|---|---|
strict | Maximum restriction, deny by default | deny-all |
standard | Balanced, allow known domains | allowlist |
permissive | Minimal restriction for development | dev-open |
Default profile: strict. Default mode: deny-all.
Modes
Modes control egress behavior within a profile:
| Mode | Behavior |
|---|---|
deny-all | No outbound network access (localhost always allowed) |
allowlist | Only explicitly allowed domains (exact + wildcard) |
dev-open | Unrestricted outbound access (development only) |
Domain Matching
Domain matching is handled by DomainMatcher (forge-core/security/domain_matcher.go), shared by both the in-process enforcer and the subprocess proxy:
- Exact match:
api.openai.commatchesapi.openai.com - Wildcard match:
*.github.commatchesapi.github.combut notgithub.com - Case insensitive:
API.OpenAI.COMmatchesapi.openai.com - Localhost bypass:
127.0.0.1,::1, andlocalhostare always allowed in all modes
IP Validation
All egress paths validate hostnames against non-standard IP formats before domain matching. The IP validator (forge-core/security/ip_validator.go) rejects SSRF bypass vectors:
| Blocked Format | Example | Reason |
|---|---|---|
| Octal | 0177.0.0.1 | Resolves to 127.0.0.1 in some parsers |
| Hexadecimal | 0x7f000001 | Resolves to 127.0.0.1 in some parsers |
| Packed decimal | 2130706433 | Resolves to 127.0.0.1 in some parsers |
| Leading zeros | 127.0.0.01 | Ambiguous parsing across languages |
| IPv6 transition (NAT64) | 64:ff9b::10.0.0.1 | Embeds private IPv4 in IPv6 |
| IPv6 transition (6to4) | 2002:0a00:0001:: | Embeds private IPv4 in IPv6 |
| IPv6 transition (Teredo) | 2001:0000:... | Embeds XOR'd IPv4 in IPv6 |
The ValidateHostIP() function is called early in both the EgressEnforcer and EgressProxy before any domain matching occurs.
Safe Dialer (DNS Rebinding Protection)
The SafeDialer (forge-core/security/safe_dialer.go) prevents DNS rebinding and TOCTOU attacks by validating resolved IPs before connecting:
- Resolves hostname to IP addresses via DNS
- Validates all resolved IPs against blocked CIDR ranges
- Dials the first safe IP directly (bypasses re-resolution)
Blocked IP ranges depend on the allowPrivateIPs setting:
| CIDR | Always Blocked | Blocked when allowPrivateIPs=false |
|---|---|---|
169.254.169.254/32 (cloud metadata) | Yes | Yes |
127.0.0.0/8 (loopback) | Yes | Yes |
::1/128 (IPv6 loopback) | Yes | Yes |
0.0.0.0/8 | Yes | Yes |
10.0.0.0/8 (RFC 1918) | — | Yes |
172.16.0.0/12 (RFC 1918) | — | Yes |
192.168.0.0/16 (RFC 1918) | — | Yes |
169.254.0.0/16 (link-local) | — | Yes |
100.64.0.0/10 (CGNAT) | — | Yes |
fc00::/7 (IPv6 ULA) | — | Yes |
fe80::/10 (IPv6 link-local) | — | Yes |
Both the EgressEnforcer and EgressProxy use SafeTransport (an http.Transport wired to the SafeDialer) for non-localhost connections.
Container-Aware Private IP Handling
In container and Kubernetes environments, pods communicate via service DNS names that resolve to RFC 1918 addresses (e.g., 10.96.x.x). Blocking these would break inter-service communication.
The allowPrivateIPs setting is resolved with this precedence:
- Explicit config —
egress.allow_private_ipsinforge.yaml - Auto-detect —
trueifInContainer()detects Docker/Kubernetes - Default —
false(block all private IPs)
| Scenario | allowPrivateIPs | RFC 1918 | Cloud Metadata | Loopback |
|---|---|---|---|---|
| Local dev | false | Blocked | Blocked | Allowed (localhost bypass) |
| Docker Desktop | true (auto) | Allowed | Blocked | Allowed (localhost bypass) |
| Kubernetes | true (auto) | Allowed | Blocked | Allowed (localhost bypass) |
Cloud metadata (169.254.169.254) is always blocked regardless of the allowPrivateIPs setting.
Narrow Private-CIDR Allowlist (issue #337 preparation)
allow_private_ips: true opens all RFC 1918 / link-local / CGNAT / ULA ranges at once — the right choice for a Kubernetes pod that talks to any service in the mesh, but too wide for an agent that only needs to reach one internal database.
allowed_private_cidrs narrows that: when allow_private_ips is false (or unset), IPs falling inside any listed CIDR bypass the private-block, and everything else private stays blocked.
egress:
mode: allowlist
allow_private_ips: false # keep RFC 1918 blocked wholesale
allowed_private_cidrs: # ...but open these two slices
- 10.20.0.0/16 # internal databases
- 172.16.42.0/24 # message brokersInvariants (checked by tests):
- Always-blocked ranges (cloud metadata
169.254.169.254, loopback,0.0.0.0/8) stay blocked even if an operator lists a CIDR that would otherwise contain them. No allowlist punches a hole in these. allow_private_ips: truesupersedes the CIDR list — the boolean means "all private allowed" and the list is redundant. A warning in that case is a good future addition, but the effective policy is safe (superset).- Invalid CIDR strings fail at config-load time, not at first dial.
Resolve()validates each entry vianet.ParseCIDR; bare IPs (missing/mask) are rejected because the config intent is range-level exemption. Non-canonical entries with host bits set (e.g.10.20.0.5/16) are also rejected — Go'snet.ParseCIDRsilently masks those to the network (widening from a single host to a /16), which is the security-wrong direction. Write10.20.0.5/32(single host) or10.20.0.0/16(range) — never the ambiguous mix. - Both
forge buildandforge runvalidate the CIDR strings — a typo trips the build, not just the first launch. 0.0.0.0/0is not restricted. Listing the whole IPv4 space here is equivalent toallow_private_ips: truein effect — cloud metadata / loopback /0.0.0.0/8still can't be reached because always-blocked wins. It's an explicit operator choice, not a hole, but if you find yourself writing it you probably wantallow_private_ips: truefor the same effect + clearer intent.- CIDRs are consulted by both the in-process
EgressEnforcer(Go clients) and the subprocessEgressProxy(skill scripts) — one policy, two enforcement paths.
Companion feature for #337: raw-TCP egress (databases, message brokers) requires this narrow-private mechanism to be reachable in the first place. Landing the CIDR allowlist first (this PR) unblocks the SOCKS5-based TCP path (follow-up PR) without opening RFC 1918 wholesale.
Raw-TCP Egress (SOCKS5)
HTTP(S) traffic goes through the HTTP forward proxy; raw TCP (Postgres, MySQL, Redis, Kafka, RabbitMQ, MongoDB, MQTT) goes through a SOCKS5 listener the same proxy binds. Both use the same enforcement engine (ValidateAndDial) — one allowlist policy, one audit shape, two client-facing protocols.
Config:
egress:
mode: allowlist
allowed_hosts: [api.stripe.com] # HTTP/HTTPS — unchanged
allowed_tcp: # raw-TCP allowlist
- db.internal:5432
- "*.brokers.internal:9092" # wildcard host, exact port
- metrics.internal:* # exact host, any port
- redis.internal:6379
allowed_private_cidrs: # required for internal targets
- 10.20.0.0/16Entry shapes:
| Shape | Example | Matches |
|---|---|---|
host:port | db.internal:5432 | db.internal:5432 only |
*.suffix:port | *.brokers.internal:9092 | broker1.brokers.internal:9092, cluster-a.brokers.internal:9092 (parent brokers.internal NOT matched) |
host:* | metrics.internal:* | any port on metrics.internal |
*.suffix:* | *.internal:* | any port on any subdomain of .internal |
Bare host (no :port) is rejected at config-load. Ports outside 1–65535 are rejected. HTTP-side allowed_hosts entries are ALSO reachable via SOCKS5 (either matcher can allow a target) — no need to duplicate.
Read allowed_hosts carefully once SOCKS5 is on. The HTTP-side allowlist is port-agnostic: a hostname listed in allowed_hosts for HTTPS use is reachable over SOCKS5 on any port. Listing api.stripe.com for HTTPS also grants api.stripe.com:22, api.stripe.com:3389, etc. via the raw-TCP path. SafeDialer still bounds the IPs it resolves to (no cloud-metadata, no unlisted-private), so it's not an SSRF hole — but it may be wider than the operator's HTTPS-only mental model. If you need port-narrow control on an HTTP hostname, remove it from allowed_hosts and add explicit allowed_tcp: [api.stripe.com:443] instead. Matches pre-existing HTTP CONNECT behavior (CONNECT also checks hostname-only), so this isn't newly introduced — but it's newly exposed to arbitrary TCP protocols.
IPv6 targets must be bracketed in the config ([::1]:5432 or [2001:db8::1]:6379). Wildcard hosts (*.suffix) are IPv4-hostname patterns — IPv6 literal wildcards aren't supported (there's no meaningful "suffix" for an IP literal).
Env injection for skill subprocesses:
The runner sets these env vars alongside the existing HTTP_PROXY/HTTPS_PROXY:
ALL_PROXY=socks5h://127.0.0.1:<port>
all_proxy=socks5h://127.0.0.1:<port>
SOCKS_PROXY=socks5h://127.0.0.1:<port>The socks5h:// scheme (with the h) forces server-side hostname resolution — the proxy needs the destination hostname to run the allowlist check and record it in the audit event. Clients that pre-resolve locally to an IP would launder the target past the domain matcher.
Client support matrix:
| Client | Native SOCKS5? | How to reach the proxy |
|---|---|---|
curl | Yes | curl --socks5-hostname "$ALL_PROXY" … |
| Go apps | Yes | golang.org/x/net/proxy reads ALL_PROXY automatically |
Python (psycopg2, redis-py, pymongo) | Partial | pip install pysocks; socks.set_default_proxy(socks.SOCKS5, …) |
Node (pg, ioredis, mongodb) | With shim | npm i socks-proxy-agent; construct the client with the agent |
psql, redis-cli, mongosh, kafka-console-consumer | No | Wrap in proxychains-ng — see caveats below |
proxychains-ng platform caveats:
| Platform | Works? | Notes |
|---|---|---|
| Linux (glibc) | ✓ | LD_PRELOAD — install via package manager |
| Linux (musl / Alpine) | ✓ | Requires proxychains built for musl |
| macOS | ✗ (mostly) | DYLD_INSERT_LIBRARIES blocked by SIP for most binaries. Use language-native SOCKS5 SDKs instead. |
| Statically-linked Go binaries | ✗ | Skip LD_PRELOAD entirely — the loader has no dynamic hook. Use net/proxy in code. |
| setuid binaries | ✗ | LD_PRELOAD stripped by dynamic linker |
Fail-closed invariants:
- Deny-all default — raw TCP is denied unless explicitly in
allowed_tcp(or in HTTP-sideallowed_hosts). - Port granularity —
db.internal:5432allows only port 5432 on that host. A client trying 5433 gets a policy denial before dial. - Private-CIDR gate — internal destinations (databases, brokers on RFC 1918) still require
allowed_private_cidrsorallow_private_ips: trueto be reachable at all. - Localhost bypass —
127.0.0.1/::1/localhostbypass the matcher (same as HTTP path). - BIND / UDP ASSOCIATE rejected — only the CONNECT command is supported; other SOCKS5 commands are refused with REP=0x07.
- No pre-resolved IPs from clients —
socks5h://scheme enforces server-side name resolution. Clients that speak plainsocks5://and pre-resolve get the IP path in SafeDialer, which either matchesallowed_private_cidrsor fails with a blocked-IP error.
Task attribution:
The HTTP path attributes egress events to task/correlation IDs via Proxy-Authorization (see #338). SOCKS5v5 (no-auth) has no equivalent channel — SOCKS5 audit events carry only the host:port and decision. This is a deliberate limitation, not an oversight; adding SOCKS5-auth-based attribution is a follow-up when there's a concrete need.
Listener lifecycle:
The SOCKS5 listener is only bound when allowed_tcp has at least one entry. Deployments that don't need raw-TCP egress see no additional port bound and no ALL_PROXY env vars.
Runtime Egress Enforcer
The EgressEnforcer (forge-core/security/egress_enforcer.go) is an http.RoundTripper that wraps a SafeTransport. Every outbound HTTP request from in-process Go code (builtins like http_request, web_search, LLM API calls) passes through it.
enforcer := security.NewEgressEnforcer(nil, security.ModeAllowlist, allowedDomains, false)
client := &http.Client{Transport: enforcer}Request validation order:
- Reject non-standard IP formats (
ValidateHostIP) - Allow localhost (bypass SafeTransport, use
http.DefaultTransport) - Check domain against allowlist (
DomainMatcher.IsAllowed) - Forward via
SafeTransport(post-DNS IP validation)
Blocked requests return: egress blocked: domain "X" not in allowlist (mode=allowlist)
The enforcer fires an OnAttempt callback for every request, enabling audit logging with domain, mode, and allow/deny decision.
Subprocess Egress Proxy
Skill scripts and cli_execute subprocesses bypass the Go-level EgressEnforcer because they use external tools like curl or wget. The EgressProxy (forge-core/security/egress_proxy.go) closes this gap.
How it works
┌─────────────────────────────────────────────────────┐
│ forge run │
│ │
│ In-process HTTP ──→ EgressEnforcer (RoundTripper) │
│ │
│ Subprocesses ──→ HTTP_PROXY ──→ EgressProxy │
│ (curl, wget, 127.0.0.1:<port> (validates │
│ python, etc.) domains) │
└─────────────────────────────────────────────────────┘- Before tool registration, Forge starts a local HTTP/HTTPS forward proxy on
127.0.0.1:0(random port) HTTP_PROXY,HTTPS_PROXY,http_proxy, andhttps_proxyenv vars are injected into every subprocess- The proxy validates each request's destination hostname against the same
DomainMatcher - Allowed requests are forwarded; blocked requests receive
403 Forbidden
Properties
| Property | Detail |
|---|---|
| Binding | 127.0.0.1:0 — localhost only, random port, never exposed externally |
| Lifecycle | Per Runner.Run() — starts before tool registration, shuts down on context cancellation |
| Isolation | Multiple forge run instances each get their own proxy on different ports |
| HTTP requests | Reads req.URL.Host, checks DomainMatcher.IsAllowed(), forwards or returns 403 |
| HTTPS CONNECT | Parses host from CONNECT host:port, validates domain, blind-relays bytes (no MITM/decryption) |
| Env vars | Sets both uppercase and lowercase forms to cover all HTTP client libraries |
| Audit | Emits same egress_allowed/egress_blocked audit events with "source": "proxy" |
When the proxy is skipped
- Container environments: When
KUBERNETES_SERVICE_HOSTis set or/.dockerenvexists, KubernetesNetworkPolicyhandles egress enforcement instead dev-openmode: No restrictions needed, proxy would be a transparent passthrough
Container detection is handled by InContainer() in forge-core/security/container.go.
Capability Bundles
Capability bundles (forge-core/security/capabilities.go) map service names to their required domains:
| Capability | Domains |
|---|---|
slack | slack.com, hooks.slack.com, api.slack.com |
telegram | api.telegram.org |
Specify capabilities in forge.yaml to automatically include their domains.
Tool Domain Inference
The tool domain inference system (forge-core/security/tool_domains.go) maps tool names to known required domains:
| Tool | Inferred Domains |
|---|---|
web_search / web-search | api.tavily.com, api.perplexity.ai |
github_api | api.github.com, github.com |
slack_notify | slack.com, hooks.slack.com |
openai_completion | api.openai.com |
anthropic_api | api.anthropic.com |
huggingface_api | api-inference.huggingface.co, huggingface.co |
google_vertex | us-central1-aiplatform.googleapis.com |
sendgrid_email | api.sendgrid.com |
twilio_sms | api.twilio.com |
aws_bedrock | bedrock-runtime.us-east-1.amazonaws.com |
azure_openai | openai.azure.com |
tavily_research | api.tavily.com |
tavily_search | api.tavily.com |
Allowlist Resolution
The resolver (forge-core/security/resolver.go) combines all domain sources:
- Validate profile and mode
- For
deny-all: return empty config (no domains allowed) - For
dev-open: return unrestricted config (all domains allowed) - For
allowlist:- Start with explicit domains from
forge.yaml - Add tool-inferred domains
- Add capability bundle domains
- Add auth-provider-derived domains (see below)
- Deduplicate and sort
- Start with explicit domains from
Auth-provider domain auto-extension
Configuring an auth.providers[] entry adds the host(s) the provider needs
to reach to the allowlist automatically — operators don't have to remember
to add sts.us-east-1.amazonaws.com themselves when they configure
aws_sigv4. The security.AuthDomains helper centralizes the mapping:
| Provider | Host(s) added |
|---|---|
oidc | host of issuer URL, host of explicit jwks_url if set |
http_verifier | host of url |
aws_sigv4 | sts.<region>.amazonaws.com (+ test-mode sts_endpoint override host) |
gcp_iap | www.gstatic.com (hardcoded, IAP JWKS lives there) |
azure_ad | login.microsoftonline.com (+ graph.microsoft.com when groups_mode: graph) |
forge init's wizard runs the Auth step before Egress so the operator
sees the full outbound surface for review in a single screen. See
Authentication for the per-provider auth model.
MCP server domain auto-extension
Configuring an mcp.servers[] entry with transport: http adds the host
of the server url to the allowlist automatically — operators don't have
to remember to add mcp.linear.app when they configure a Linear MCP
server. See security.MCPDomains for the canonical mapping.
OTel collector domain auto-extension (OTel v1, #107)
Configuring observability.tracing with an endpoint adds the
collector's hostname to the allowlist automatically. Without this, a
deployment with tracing enabled in forge.yaml would ship a
NetworkPolicy that blocks OTLP traffic — spans accumulate in the
batch processor and drop on shutdown timeout, leaving the operator
with an inexplicably empty trace backend.
| Source field | Host added |
|---|---|
observability.tracing.endpoint (when enabled: true) | bare hostname (port stripped) |
The auto-merge fires at both build time (forge package →
egress_allowlist.json → generated NetworkPolicy) and runtime
(forge run dev mode), so dev and prod behave identically. Disabled
tracing produces no entry — turning tracing off in yaml does NOT
leave a stale entry punched through. Malformed endpoints are silently
skipped: the build never blocks on telemetry config. See
Observability — Tracing
for the full reference.
Build Artifacts
The EgressStage generates:
egress_allowlist.json
{
"profile": "standard",
"mode": "allowlist",
"allowed_domains": ["api.example.com"],
"tool_domains": ["api.tavily.com"],
"all_domains": ["api.example.com", "api.tavily.com"]
}Empty arrays are always [], never null.
Kubernetes network-policy.yaml
Generated by GenerateK8sNetworkPolicy():
- deny-all: Empty egress rules (
egress: []) - allowlist: Allows ports 80/443 with domain annotations
- dev-open: Allows ports 80/443 without restrictions
The NetworkPolicy uses pod selector app: <agent-id> and includes domain annotations for external DNS-based policy controllers.
Configuration
In forge.yaml:
egress:
profile: standard
mode: allowlist
allowed_domains:
- api.example.com
- "*.github.com"
- hooks.slack.com
capabilities:
- slack
- telegram
allow_private_ips: false # default: auto-detect from container env
allowed_private_cidrs: # narrow private-IP allowlist (see above)
- 10.20.0.0/16The allow_private_ips field controls whether RFC 1918 addresses are allowed through the SafeDialer. When omitted, it defaults to true inside containers (detected via KUBERNETES_SERVICE_HOST or /.dockerenv) and false otherwise. Cloud metadata (169.254.169.254) is always blocked.
allowed_private_cidrs (added in issue #337) is a narrower alternative: it lets you reach a specific slice of the private-IP space (e.g. only the internal-database subnet) without opening RFC 1918 wholesale. See "Narrow Private-CIDR Allowlist" above.
Production vs Development
| Setting | Production | Development |
|---|---|---|
| Profile | strict or standard | permissive |
| Mode | deny-all or allowlist | dev-open |
| Dev tools | Filtered out | Included |
| Network policy | Enforced | Not generated |
| Egress proxy | Active (allowlist/deny-all) | Skipped (dev-open) |
| Container egress | NetworkPolicy enforced | Proxy enforced locally |
Audit Events
Both the enforcer and proxy emit structured audit events:
{"event":"egress_allowed","correlation_id":"a1b2c3d4","task_id":"task-1","fields":{"domain":"api.tavily.com","mode":"allowlist"}}
{"event":"egress_blocked","correlation_id":"a1b2c3d4","task_id":"task-1","fields":{"domain":"evil.com","mode":"allowlist"}}
{"event":"egress_allowed","correlation_id":"a1b2c3d4","task_id":"task-1","fields":{"domain":"api.tavily.com","mode":"allowlist","source":"proxy"}}Events without "source" come from the in-process enforcer; events with "source": "proxy" come from the subprocess proxy. Both carry correlation_id (the invocation ID) and task_id. The in-process enforcer reads them from the request context; the proxy recovers them from the Proxy-Authorization credentials the subprocess replays — the runner stamps the task/invocation IDs into the injected HTTP_PROXY URL as userinfo, and standard HTTP clients echo that back as a Basic proxy-auth header on every request and CONNECT. A binary that ignores proxy credentials is still enforced and audited, but its proxy events omit the identity fields (issue #338).
Related Files
| File | Purpose |
|---|---|
forge-core/security/types.go | Profile and mode types, EgressConfig |
forge-core/security/ip_validator.go | Strict IP parsing, CIDR blocking, IPv6 transition detection |
forge-core/security/safe_dialer.go | Post-DNS-resolution IP validation, SafeTransport |
forge-core/security/domain_matcher.go | DomainMatcher — shared exact/wildcard matching logic |
forge-core/security/egress_enforcer.go | EgressEnforcer — in-process http.RoundTripper |
forge-core/security/egress_proxy.go | EgressProxy — localhost HTTP/HTTPS forward proxy |
forge-core/security/redirect.go | Cross-origin redirect credential stripping |
forge-core/security/container.go | InContainer() — Docker/Kubernetes detection |
forge-core/security/resolver.go | Allowlist resolution logic |
forge-core/security/capabilities.go | Capability bundle definitions |
forge-core/security/tool_domains.go | Tool domain inference |
forge-core/security/allowlist.go | JSON allowlist generation |
forge-core/security/network_policy.go | K8s NetworkPolicy generation |
forge-cli/tools/exec.go | SkillCommandExecutor — proxy env injection for skill scripts |
forge-cli/tools/cli_execute.go | CLIExecuteTool — proxy env injection for CLI binaries |
forge-cli/runtime/runner.go | Proxy lifecycle management in Run() |