Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/security/egress-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,89 @@ Invariants (checked by tests):

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, NATS, 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:**

```yaml
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:**

| 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`:

```sh
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-side `allowed_hosts`).
- **Port granularity** — `db.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 bypass** — `127.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 clients** — `socks5h://` 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.
Expand Down
16 changes: 8 additions & 8 deletions forge-cli/build/egress_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ func (s *EgressStage) Execute(ctx context.Context, bc *pipeline.BuildContext) er
// time out depending on which side notices first.
allowed = append(allowed, security.LLMProviderDomains(bc.Config)...)

// Pass allowed_private_cidrs through so `forge build` validates the
// strings at build time (same fail-loud posture as `forge run`). The
// build artifacts themselves (`GenerateAllowlistJSON`,
// `GenerateK8sNetworkPolicy`) don't consume the CIDRs — runtime IP
// enforcement re-resolves from raw config — but a typo in
// `allowed_private_cidrs` should fail the build, not sail through and
// trip only on first `forge run`. (#348 review nit 1.)
resolved, err := security.Resolve(cfg.Profile, cfg.Mode, allowed, toolNames, cfg.Capabilities, cfg.AllowedPrivateCIDRs)
// Pass allowed_private_cidrs and allowed_tcp through so `forge build`
// validates the strings at build time (same fail-loud posture as
// `forge run`). The build artifacts themselves (`GenerateAllowlistJSON`,
// `GenerateK8sNetworkPolicy`) don't consume either list — runtime
// enforcement re-resolves from raw config — but a typo should fail the
// build, not sail through and trip only on first `forge run`.
// (#348 review nit 1, extended to allowed_tcp for #337.)
resolved, err := security.Resolve(cfg.Profile, cfg.Mode, allowed, toolNames, cfg.Capabilities, cfg.AllowedPrivateCIDRs, cfg.AllowedTCP)
if err != nil {
return fmt.Errorf("resolving egress: %w", err)
}
Expand Down
31 changes: 27 additions & 4 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ func (r *Runner) Run(ctx context.Context) error {
var egressClient *http.Client
var egressProxy *security.EgressProxy
var proxyURL string
var socksURL string
egressToolNames := make([]string, len(r.cfg.Config.Tools))
for i, t := range r.cfg.Config.Tools {
egressToolNames[i] = t.Name
Expand Down Expand Up @@ -663,6 +664,7 @@ func (r *Runner) Run(ctx context.Context) error {
egressToolNames,
r.cfg.Config.Egress.Capabilities,
r.cfg.Config.Egress.AllowedPrivateCIDRs,
r.cfg.Config.Egress.AllowedTCP,
)
if egressErr != nil {
r.logger.Warn("failed to resolve egress config, using default", map[string]any{"error": egressErr.Error()})
Expand Down Expand Up @@ -734,6 +736,17 @@ func (r *Runner) Run(ctx context.Context) error {
if (!security.InContainer() && egressCfg.Mode != security.ModeDevOpen) || browserActive {
matcher := security.NewDomainMatcher(egressCfg.Mode, egressCfg.AllDomains)
egressProxy = security.NewEgressProxy(matcher, allowPrivateIPs, allowedPrivateCIDRs)

// #337 — install the port-aware raw-TCP matcher. Empty list =>
// SOCKS5 listener stays disabled (no extra port bound). The
// Resolve() step above already validated the entries; a parse
// failure here would be an internal invariant break.
if tcpMatcher, tcpErr := security.NewTCPMatcher(egressCfg.AllowedTCP); tcpErr != nil {
r.logger.Warn("failed to build tcp allowlist, raw-TCP egress disabled", map[string]any{"error": tcpErr.Error()})
} else {
egressProxy.SetTCPMatcher(tcpMatcher)
}

egressProxy.OnAttempt = func(a security.EgressAttempt) {
event := coreruntime.AuditEgressAllowed
if !a.Allowed {
Expand Down Expand Up @@ -763,7 +776,14 @@ func (r *Runner) Run(ctx context.Context) error {
}
egressProxy = nil
} else {
r.logger.Info("egress proxy started", map[string]any{"url": proxyURL})
// #337 — capture the SOCKS5 URL for env injection into skill
// subprocesses. Empty when raw-TCP egress wasn't configured.
socksURL = egressProxy.SOCKSURL()
fields := map[string]any{"http_url": proxyURL}
if socksURL != "" {
fields["socks_url"] = socksURL
}
r.logger.Info("egress proxy started", fields)
}
}
}
Expand Down Expand Up @@ -940,7 +960,7 @@ func (r *Runner) Run(ctx context.Context) error {
if r.derivedCLIConfig != nil {
envPass = r.derivedCLIConfig.EnvPassthrough
}
rss := clitools.NewRunSkillScriptTool(r.cfg.WorkDir, proxyURL, envPass)
rss := clitools.NewRunSkillScriptTool(r.cfg.WorkDir, proxyURL, socksURL, envPass)
if regErr := reg.Register(rss); regErr != nil {
r.logger.Warn("failed to register run_skill_script", map[string]any{"error": regErr.Error()})
}
Expand Down Expand Up @@ -1010,7 +1030,7 @@ func (r *Runner) Run(ctx context.Context) error {
}

// Register skill tools from skill files
r.registerSkillTools(reg, proxyURL)
r.registerSkillTools(reg, proxyURL, socksURL)

// Remove denied tools from the registry. The effective deny
// list is the union of forge.yaml's denies (via the derived
Expand Down Expand Up @@ -3419,8 +3439,10 @@ func (r *Runner) resolveBinarySkillPath(entry contract.SkillEntry) (string, erro
}

// registerSkillTools scans skill files for skill entries that have associated
// skill:run commands and registers them as tools. socksURL is the raw-TCP
// SOCKS5 egress URL — empty when raw-TCP egress isn't configured.
// scripts. Each script-backed skill is registered as a first-class tool in the registry.
func (r *Runner) registerSkillTools(reg *tools.Registry, proxyURL string) {
func (r *Runner) registerSkillTools(reg *tools.Registry, proxyURL string, socksURL string) {
matches := r.discoverSkillFiles()

var registered int
Expand Down Expand Up @@ -3484,6 +3506,7 @@ func (r *Runner) registerSkillTools(reg *tools.Registry, proxyURL string) {
WorkDir: r.cfg.WorkDir,
EnvVars: envVars,
ProxyURL: proxyURL,
SOCKSURL: socksURL,
Model: modelName,
}

Expand Down
19 changes: 18 additions & 1 deletion forge-cli/tools/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ type SkillCommandExecutor struct {
Timeout time.Duration
WorkDir string // agent working directory — script paths are relative to this
EnvVars []string // extra env var names to pass through (e.g., "TAVILY_API_KEY")
ProxyURL string // egress proxy URL (e.g., "http://127.0.0.1:54321")
ProxyURL string // HTTP egress proxy URL (e.g., "http://127.0.0.1:54321")
SOCKSURL string // SOCKS5 egress proxy URL (e.g., "socks5h://127.0.0.1:54322"); empty when raw-TCP egress disabled
Model string // configured LLM model name — passed as REVIEW_MODEL to skill scripts
}

Expand Down Expand Up @@ -162,6 +163,22 @@ func (e *SkillCommandExecutor) Run(ctx context.Context, command string, args []s
"https_proxy="+proxyURL,
)
}
if e.SOCKSURL != "" {
// #337 — raw-TCP egress for databases / message brokers via SOCKS5.
// `socks5h://` (with h) forces server-side hostname resolution: the
// proxy needs the hostname string to run the allowlist check and
// record the audit event. Clients that pre-resolve to an IP would
// launder the target past the domain matcher.
//
// No identity injection here: SOCKS5v5 (no-auth) has no channel for
// per-request credentials — task/correlation attribution is HTTP-only.
// This is a known limitation documented in the egress-control doc.
env = append(env,
"ALL_PROXY="+e.SOCKSURL,
"all_proxy="+e.SOCKSURL,
"SOCKS_PROXY="+e.SOCKSURL,
)
}
// Issue #182 — propagate W3C trace context + curated OTel SDK env
// vars so the subprocess's spans nest under the parent agent's
// `tool.<name>` span. Without this the child starts a fresh root
Expand Down
6 changes: 5 additions & 1 deletion forge-cli/tools/run_skill_script.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,19 @@ import (
type RunSkillScriptTool struct {
workDir string
proxyURL string
socksURL string
envVars []string
timeout time.Duration
}

// NewRunSkillScriptTool constructs the tool rooted at the agent working
// directory, wired to the egress proxy and the skill env passthrough.
func NewRunSkillScriptTool(workDir, proxyURL string, envVars []string) *RunSkillScriptTool {
// socksURL is the raw-TCP SOCKS5 egress URL (empty when unconfigured).
func NewRunSkillScriptTool(workDir, proxyURL, socksURL string, envVars []string) *RunSkillScriptTool {
return &RunSkillScriptTool{
workDir: workDir,
proxyURL: proxyURL,
socksURL: socksURL,
envVars: envVars,
timeout: 120 * time.Second,
}
Expand Down Expand Up @@ -113,6 +116,7 @@ func (t *RunSkillScriptTool) Execute(ctx context.Context, args json.RawMessage)
WorkDir: dir,
EnvVars: t.envVars,
ProxyURL: t.proxyURL,
SOCKSURL: t.socksURL,
}
out, runErr := exec.Run(ctx, interp, []string{input.Path, jsonArgs}, nil)
if runErr != nil {
Expand Down
4 changes: 2 additions & 2 deletions forge-cli/tools/run_skill_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func runScript(t *testing.T, root, skill, path string, args map[string]any) stri
t.Helper()
argsJSON, _ := json.Marshal(args)
in, _ := json.Marshal(map[string]any{"skill": skill, "path": path, "args": json.RawMessage(argsJSON)})
out, err := NewRunSkillScriptTool(root, "", nil).Execute(context.Background(), in)
out, err := NewRunSkillScriptTool(root, "", "", nil).Execute(context.Background(), in)
if err != nil {
t.Fatalf("Execute(%s): %v", path, err)
}
Expand Down Expand Up @@ -148,7 +148,7 @@ func TestRunSkillScript_ArgsMustBeObject(t *testing.T) {
writeSkillScript(t, root, "owl", "scripts/x.sh", "#!/usr/bin/env bash\necho '{}'\n")
for _, badArgs := range []string{`"hello"`, `42`, `[1,2]`, `true`} {
in := `{"skill":"owl","path":"scripts/x.sh","args":` + badArgs + `}`
out, err := NewRunSkillScriptTool(root, "", nil).Execute(context.Background(), json.RawMessage(in))
out, err := NewRunSkillScriptTool(root, "", "", nil).Execute(context.Background(), json.RawMessage(in))
if err != nil {
t.Fatalf("Execute: %v", err)
}
Expand Down
1 change: 1 addition & 0 deletions forge-core/forgecore.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func Compile(req CompileRequest) (*CompileResult, error) {
toolNames,
nil, // capabilities resolved from profile
req.Config.Egress.AllowedPrivateCIDRs,
req.Config.Egress.AllowedTCP,
)
if err != nil {
return nil, err
Expand Down
Loading
Loading