Skip to content

Egress Proxy

For actions using egress.profile = "proxy_allowlist", LatchGate enforces two independent layers of egress control: the kernel’s sink validation at the host I/O layer, and a forward HTTP proxy (Squid) enforcing a domain allowlist at the network transport layer.

For production deployments with proxy_allowlist actions, the egress proxy is strongly recommended. Without it, the gate starts with a warning and uses kernel-only enforcement (Layer 1: sink validation + SSRF protection + manifest domain allowlists). The proxy adds an independent Layer 2 backstop — if a kernel-layer bug would ever let a request reach the network with an unauthorized target, the proxy catches it.

The kernel’s validate_sink() is the primary authorization check. It runs before each host I/O call and rejects targets not in the grant’s allowed_sinks. But a single layer is a single point of failure:

  • A regex or parser bug could accept a malformed URL that bypasses validation
  • A future refactor could move network calls to a path that skips the check
  • A compromised dependency could leak traffic to unauthorized domains

The proxy is a cheap, independent backstop. Squid has been hardened for decades; it has no knowledge of LatchGate internals. It only knows the allowlist of domains and refuses everything else. The proxy cannot be bypassed by kernel bugs because it sits at the network layer — it intercepts all HTTP egress regardless of which code path issued the request.

Provider (WASM sandbox)
│ io-http request (target URL)
Host I/O layer
├─ validate_sink(url) ← layer 1: kernel
│ rejects if url's host not in grant.allowed_sinks
├─ inject credentials (header or conn string)
└─ send via reqwest through http_proxy
Squid forward proxy
├─ ACL check against allowed_domains ← layer 2: network
│ rejects if host not in allowlist
└─ forwards to destination
External system

Both layers must pass for the request to succeed. If either rejects, the action fails closed.

Egress allowlists are generated from action manifests — never hand-curated. The manifest is the source of truth; every downstream enforcement layer (Squid, CI env vars) consumes output from the same generator:

Terminal window
# Extract allowed domains from manifest YAML files to generate egress allowlists.
# Use the deploy/pin-digests.sh script or parse manifests directly:
grep -h 'allowed_domains' manifests/*.yaml # quick inspection

CI should fail if checked-in lists diverge from the domains declared in action manifests.

LatchGate supports two egress proxy setups. Pick one based on how your allowlist changes.

Section titled “Mode A — live sync (recommended for production)”

The gate atomically writes (manifest_domains ∪ learned_domains) ∩ runtime_allowlist to a file on every relevant event (startup, domains add/remove/clear, approval domain learning), then runs a reload command. Squid reads the same file via a shared volume. Adding a learned domain through the approval flow reflects in Squid within seconds — no redeploy.

egress_proxy_url = "http://squid:3128"
egress_live_allowlist_path = "/var/run/latchgate/egress/allowlist.txt"
egress_reload_command = "squid -k reconfigure"

Docker Compose:

services:
gate:
volumes:
- egress-allowlist:/var/run/latchgate/egress
squid:
image: ghcr.io/latchgate-ai/latchgate-egress:latest
volumes:
- egress-allowlist:/var/run/latchgate/egress:rw
networks:
- toolnet
- egress
volumes:
egress-allowlist:
networks:
toolnet:
internal: true # providers have no direct internet
egress:
driver: bridge # Squid's outbound path

The published image ghcr.io/latchgate-ai/latchgate-egress:<version> ships with a default-deny squid.conf and expects the allowlist on a shared volume. It is rebuilt by the release pipeline on every version.

Mode B — offline / CI (static allowlist)

Section titled “Mode B — offline / CI (static allowlist)”

For environments without live sync — CI pipelines, airgapped deployments — generate the allowlist once and mount it read-only:

Terminal window
# Generate Squid allowlist from the allowed_domains in action manifests.
# Extract domains for the actions you need:
grep -A5 'allowed_domains' manifests/http_fetch.yaml manifests/github_read.yaml manifests/gmail_send.yaml \
| grep '^\s*-' | sed 's/.*- "//' | sed 's/"//' | sort -u > deploy/squid/allowlist.txt
egress_proxy_url = "http://squid:3128"
# No egress_live_allowlist_path — the file is pre-generated and shipped.

Regenerate and redeploy whenever an action or manifest domain changes. A CI check comparing generator output to the checked-in file catches drift early:

Terminal window
# Compare manifest-declared domains against checked-in allowlist

In either mode, the operator can narrow the effective allowlist at runtime without touching manifests. Set egress_runtime_allowlist in latchgate.toml (this field is TOML-only and cannot be overridden via environment variables). The kernel and the live-synced file both compute:

effective = (manifest_domains ∪ learned_domains) ∩ egress_runtime_allowlist

This can only remove domains from the manifest set, never add — a domain absent from any manifest cannot become reachable through this field. Typical use: deploying a subset of actions and restricting network reach to only the domains those actions need.

SECURITY: operator-controlled only. Agent or caller input must never flow into this field.

Terminal window
latchgate doctor

Expected output includes:

Dependencies
✓ egress_proxy reachable at http://squid:3128

In live-sync mode, confirm the gate wrote the allowlist:

Terminal window
docker exec <squid-container> cat /var/run/latchgate/egress/allowlist.txt

Add a learned domain and watch the file update.

In the TUI (Allowlists screen, 5, Domains sub-tab): use / to select the action, press a to add a domain, x to remove the selected one, and c to clear all learned domains for the action.

CLI equivalent
Terminal window
latchgate domains add web_read newapi.example.com
docker exec <squid-container> cat /var/run/latchgate/egress/allowlist.txt # newapi.example.com now present

If egress_proxy is configured but status: false, the proxy is unreachable. Fix before proceeding — the gate’s readyz probe reports degraded and executions depending on proxy egress will fail.

The published ghcr.io/latchgate-ai/latchgate-egress image and the reference deploy/squid/squid.conf enforce default-deny with SSRF protection at the network layer:

# Listener
http_port 3128
# Domain allowlist (live-synced by the gate in Mode A,
# pre-generated in Mode B).
acl allowed_domains dstdomain "/var/run/latchgate/egress/allowlist.txt"
# SSRF protection — a compromised kernel or WASM provider must not reach
# internal infrastructure, cloud metadata, or loopback via the proxy.
acl to_private dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
acl to_cgnat dst 100.64.0.0/10
acl to_metadata dst 169.254.169.254
# Port restrictions
acl SSL_ports port 443
acl Safe_ports port 80
acl Safe_ports port 443
acl CONNECT method CONNECT
# Access rules (first match wins)
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access deny to_localhost
http_access deny to_private
http_access deny to_cgnat
http_access deny to_metadata
http_access allow allowed_domains
http_access deny all
# Security hardening
forwarded_for delete
cache deny all
# Logging to stdout/stderr for container log collection
access_log stdio:/dev/stdout
cache_log /dev/stderr

The config rejects all private IP ranges (RFC 1918), carrier-grade NAT, loopback, and the AWS/GCP metadata endpoint 169.254.169.254 regardless of what is in the allowlist — a second SSRF layer beyond the kernel’s DNS resolution check. Caching is disabled: this is a security proxy, not a performance one. forwarded_for delete strips the X-Forwarded-For header so internal client IPs never leak to upstream destinations.

Squid’s dstdomain ACL supports leading-dot wildcards:

.example.com

matches api.example.com, www.example.com, and example.com itself. Without the leading dot, only the exact hostname matches. The generator emits whatever form you configured in the manifest.

All HTTP actions return 502 action_execution_failed

Section titled “All HTTP actions return 502 action_execution_failed”

Check whether the proxy is reachable from the gate’s network:

Terminal window
curl -x http://squid:3128 https://api.github.com/

If this fails, Squid is down or unreachable. Check container health, network policies, and the egress_proxy_url value.

TCP_DENIED/403 | CONNECT api.new-service.com:443

The domain is not in the effective allowlist. Either:

  1. Add it to the relevant manifest’s egress.allowed_domains and redeploy (Mode B) or let the gate live-sync (Mode A)
  2. Approve an action with latchgate approvals approve <id> --learn-domain api.new-service.com (Mode A only — learned domains)
  3. In Mode B, regenerate the Squid allowlist from manifest YAML files and reload Squid

Never bypass the proxy ACL to unblock a domain — the point is defense-in-depth.

Squid returns 407 (Proxy Authentication Required)

Section titled “Squid returns 407 (Proxy Authentication Required)”

LatchGate does not authenticate to the proxy. If you need authenticated proxy access, use a network-layer policy (firewall rule, iptables) to restrict which hosts can reach the proxy port — don’t enable Squid’s basic auth, which would require LatchGate to store proxy credentials.

In live-sync mode, the gate runs egress_reload_command after writing the file. If the command fails (typo, missing binary, permission denied), the file updates but Squid keeps the old ACL in memory. Check the gate’s logs for reload errors and verify the command works from the gate container:

Terminal window
docker exec <gate-container> squid -k reconfigure

The proxy adds one TCP hop. Typical overhead is <5 ms for same-network deployments, up to 50 ms over a WAN. For latency-sensitive use cases, colocate Squid with the gate (same host, same container network, or sidecar).

Squid holds one open connection per concurrent request. Size the proxy container to match the WASM concurrency limit (compile-time constant: 4).

Without egress_proxy_url, the gate starts with a warning and uses kernel-only enforcement for proxy_allowlist actions. The kernel’s validate_sink() still checks every target URL against manifest domain allowlists, blocks SSRF targets, and pins DNS. Only the network-layer proxy backstop is absent.

This is the default in embedded mode (latchgate up without --infra). For single-instance deployments where the WASM sandbox has no network access and all egress goes through host I/O with validated sinks, kernel-only enforcement is sufficient. For multi-instance or high-assurance deployments, configure the proxy for defense-in-depth.

For the configuration reference, see Configuration. For production hardening checklist, see Deployment. For domain management, see latchgate domains.