initializdocs
DeveloperForge runtimeSecurity

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:

  1. Build time — Generates allowlist artifacts and Kubernetes NetworkPolicy manifests for container-level enforcement
  2. Runtime — An in-process EgressEnforcer (Go http.RoundTripper) validates every outbound HTTP request, and a local EgressProxy enforces the same rules on subprocess HTTP traffic (skill scripts, cli_execute)

The system resolves allowed domains from three sources:

  1. Explicit domains — Listed in forge.yaml
  2. Tool domains — Inferred from registered tools
  3. Capability bundles — Pre-defined domain sets for common services

Profiles

Profiles set the overall security posture. Defined in forge-core/security/types.go:

ProfileDescriptionDefault Mode
strictMaximum restriction, deny by defaultdeny-all
standardBalanced, allow known domainsallowlist
permissiveMinimal restriction for developmentdev-open

Default profile: strict. Default mode: deny-all.

Modes

Modes control egress behavior within a profile:

ModeBehavior
deny-allNo outbound network access (localhost always allowed)
allowlistOnly explicitly allowed domains (exact + wildcard)
dev-openUnrestricted 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.com matches api.openai.com
  • Wildcard match: *.github.com matches api.github.com but not github.com
  • Case insensitive: API.OpenAI.COM matches api.openai.com
  • Localhost bypass: 127.0.0.1, ::1, and localhost are 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 FormatExampleReason
Octal0177.0.0.1Resolves to 127.0.0.1 in some parsers
Hexadecimal0x7f000001Resolves to 127.0.0.1 in some parsers
Packed decimal2130706433Resolves to 127.0.0.1 in some parsers
Leading zeros127.0.0.01Ambiguous parsing across languages
IPv6 transition (NAT64)64:ff9b::10.0.0.1Embeds 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:

  1. Resolves hostname to IP addresses via DNS
  2. Validates all resolved IPs against blocked CIDR ranges
  3. Dials the first safe IP directly (bypasses re-resolution)

Blocked IP ranges depend on the allowPrivateIPs setting:

CIDRAlways BlockedBlocked when allowPrivateIPs=false
169.254.169.254/32 (cloud metadata)YesYes
127.0.0.0/8 (loopback)YesYes
::1/128 (IPv6 loopback)YesYes
0.0.0.0/8YesYes
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:

  1. Explicit configegress.allow_private_ips in forge.yaml
  2. Auto-detecttrue if InContainer() detects Docker/Kubernetes
  3. Defaultfalse (block all private IPs)
ScenarioallowPrivateIPsRFC 1918Cloud MetadataLoopback
Local devfalseBlockedBlockedAllowed (localhost bypass)
Docker Desktoptrue (auto)AllowedBlockedAllowed (localhost bypass)
Kubernetestrue (auto)AllowedBlockedAllowed (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 brokers

Invariants (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: true supersedes 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 via net.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's net.ParseCIDR silently masks those to the network (widening from a single host to a /16), which is the security-wrong direction. Write 10.20.0.5/32 (single host) or 10.20.0.0/16 (range) — never the ambiguous mix.
  • Both forge build and forge run validate the CIDR strings — a typo trips the build, not just the first launch.
  • 0.0.0.0/0 is not restricted. Listing the whole IPv4 space here is equivalent to allow_private_ips: true in effect — cloud metadata / loopback / 0.0.0.0/8 still 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 want allow_private_ips: true for the same effect + clearer intent.
  • CIDRs are consulted by both the in-process EgressEnforcer (Go clients) and the subprocess EgressProxy (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/16

Entry shapes:

ShapeExampleMatches
host:portdb.internal:5432db.internal:5432 only
*.suffix:port*.brokers.internal:9092broker1.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:

ClientNative SOCKS5?How to reach the proxy
curlYescurl --socks5-hostname "$ALL_PROXY" …
Go appsYesgolang.org/x/net/proxy reads ALL_PROXY automatically
Python (psycopg2, redis-py, pymongo)Partialpip install pysocks; socks.set_default_proxy(socks.SOCKS5, …)
Node (pg, ioredis, mongodb)With shimnpm i socks-proxy-agent; construct the client with the agent
psql, redis-cli, mongosh, kafka-console-consumerNoWrap in proxychains-ng — see caveats below

proxychains-ng platform caveats:

PlatformWorks?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 binariesSkip LD_PRELOAD entirely — the loader has no dynamic hook. Use net/proxy in code.
setuid binariesLD_PRELOAD stripped by dynamic linker

Fail-closed invariants:

  • Deny-all default — raw TCP is denied unless explicitly in allowed_tcp (or in HTTP-side allowed_hosts).
  • Port granularitydb.internal:5432 allows 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_cidrs or allow_private_ips: true to be reachable at all.
  • Localhost bypass127.0.0.1 / ::1 / localhost bypass 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 clientssocks5h:// scheme enforces server-side name resolution. Clients that speak plain socks5:// and pre-resolve get the IP path in SafeDialer, which either matches allowed_private_cidrs or 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:

  1. Reject non-standard IP formats (ValidateHostIP)
  2. Allow localhost (bypass SafeTransport, use http.DefaultTransport)
  3. Check domain against allowlist (DomainMatcher.IsAllowed)
  4. 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)     │
└─────────────────────────────────────────────────────┘
  1. Before tool registration, Forge starts a local HTTP/HTTPS forward proxy on 127.0.0.1:0 (random port)
  2. HTTP_PROXY, HTTPS_PROXY, http_proxy, and https_proxy env vars are injected into every subprocess
  3. The proxy validates each request's destination hostname against the same DomainMatcher
  4. Allowed requests are forwarded; blocked requests receive 403 Forbidden

Properties

PropertyDetail
Binding127.0.0.1:0 — localhost only, random port, never exposed externally
LifecyclePer Runner.Run() — starts before tool registration, shuts down on context cancellation
IsolationMultiple forge run instances each get their own proxy on different ports
HTTP requestsReads req.URL.Host, checks DomainMatcher.IsAllowed(), forwards or returns 403
HTTPS CONNECTParses host from CONNECT host:port, validates domain, blind-relays bytes (no MITM/decryption)
Env varsSets both uppercase and lowercase forms to cover all HTTP client libraries
AuditEmits same egress_allowed/egress_blocked audit events with "source": "proxy"

When the proxy is skipped

  • Container environments: When KUBERNETES_SERVICE_HOST is set or /.dockerenv exists, Kubernetes NetworkPolicy handles egress enforcement instead
  • dev-open mode: 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:

CapabilityDomains
slackslack.com, hooks.slack.com, api.slack.com
telegramapi.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:

ToolInferred Domains
web_search / web-searchapi.tavily.com, api.perplexity.ai
github_apiapi.github.com, github.com
slack_notifyslack.com, hooks.slack.com
openai_completionapi.openai.com
anthropic_apiapi.anthropic.com
huggingface_apiapi-inference.huggingface.co, huggingface.co
google_vertexus-central1-aiplatform.googleapis.com
sendgrid_emailapi.sendgrid.com
twilio_smsapi.twilio.com
aws_bedrockbedrock-runtime.us-east-1.amazonaws.com
azure_openaiopenai.azure.com
tavily_researchapi.tavily.com
tavily_searchapi.tavily.com

Allowlist Resolution

The resolver (forge-core/security/resolver.go) combines all domain sources:

  1. Validate profile and mode
  2. For deny-all: return empty config (no domains allowed)
  3. For dev-open: return unrestricted config (all domains allowed)
  4. 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

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:

ProviderHost(s) added
oidchost of issuer URL, host of explicit jwks_url if set
http_verifierhost of url
aws_sigv4sts.<region>.amazonaws.com (+ test-mode sts_endpoint override host)
gcp_iapwww.gstatic.com (hardcoded, IAP JWKS lives there)
azure_adlogin.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 fieldHost added
observability.tracing.endpoint (when enabled: true)bare hostname (port stripped)

The auto-merge fires at both build time (forge packageegress_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/16

The 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

SettingProductionDevelopment
Profilestrict or standardpermissive
Modedeny-all or allowlistdev-open
Dev toolsFiltered outIncluded
Network policyEnforcedNot generated
Egress proxyActive (allowlist/deny-all)Skipped (dev-open)
Container egressNetworkPolicy enforcedProxy 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).

FilePurpose
forge-core/security/types.goProfile and mode types, EgressConfig
forge-core/security/ip_validator.goStrict IP parsing, CIDR blocking, IPv6 transition detection
forge-core/security/safe_dialer.goPost-DNS-resolution IP validation, SafeTransport
forge-core/security/domain_matcher.goDomainMatcher — shared exact/wildcard matching logic
forge-core/security/egress_enforcer.goEgressEnforcer — in-process http.RoundTripper
forge-core/security/egress_proxy.goEgressProxy — localhost HTTP/HTTPS forward proxy
forge-core/security/redirect.goCross-origin redirect credential stripping
forge-core/security/container.goInContainer() — Docker/Kubernetes detection
forge-core/security/resolver.goAllowlist resolution logic
forge-core/security/capabilities.goCapability bundle definitions
forge-core/security/tool_domains.goTool domain inference
forge-core/security/allowlist.goJSON allowlist generation
forge-core/security/network_policy.goK8s NetworkPolicy generation
forge-cli/tools/exec.goSkillCommandExecutor — proxy env injection for skill scripts
forge-cli/tools/cli_execute.goCLIExecuteTool — proxy env injection for CLI binaries
forge-cli/runtime/runner.goProxy lifecycle management in Run()

On this page