Skip to content
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,24 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
destination resolved into loopback, link-local, private or this host's own
address space and no permit covered it. The Finisher sees an allow with
status 403, like the 426, and `/v1/usage` does not count it as an error.
**A 403 `destination_denied` refusal is the egress allow list's answer, not
the upstream's** (`listener.egress_allow_hosts`, or `MOCA_EGRESS_ALLOW`;
#1483): the destination was not on the deny-by-default list, and the proxy
refused it by name — header `X-Moca-Egress-Error`, text/plain body
`destination_denied: <host>\n`, the moca egress conformance contract's
shape — before the pipeline, before DNS and before any dial. Off-list
CONNECT targets, absolute-form hosts, and the target a plugin's redirect
moved a request to all land this way, as a `phase: "denied"` row carrying
an `egress-allowlist` invocation whose `details.host` names the host; the
row's own `host` is the full authority, as every outbound row's is.
**A skip_hosts pattern cannot exempt an off-list host:** the allowlist is
checked first — it is the outer boundary, and a bypass must not become an
egress bypass.
**The two 403s are different boundaries and compose** — the allow list
judges the NAME the client asked for, before DNS; the egress guard judges
the ADDRESS that name resolved to, at the dial. A host can be on the
allow list and still be refused `destination_private`, which is why an
allow-list entry is not a permit for the guard.
**A request whose body could not be buffered is the other exception:** it never
reached the pipeline, so its request row carries no
parse and no Finisher runs. Over the forward proxy's `maxRequestBodySize` (32 MiB)
Expand Down Expand Up @@ -1191,6 +1209,15 @@ resulting `/shared/client-id.txt` and `/shared/client-secret.txt`.
```
Any change to `listener.skip_hosts` requires a pod restart (same rule as other `listener.*` fields). Do NOT add hosts here that need IBAC / token-exchange policy applied — bypass means bypass.

- **Egress boundary — `listener.egress_allow_hosts`** (#1483, MOCA MI1 §8.5): the forward proxy's deny-by-default destination allow list. When non-empty, an outbound destination not on the list — a CONNECT target, an absolute-form `http://` host, or the host a plugin's redirect moved a request to — is refused with 403 `destination_denied` naming the host, in the moca egress conformance contract's refusal shape (header `X-Moca-Egress-Error`, text/plain body `destination_denied: <host>\n`), before the pipeline, before DNS resolution and before any dial, so an off-list host is answered by name rather than left to time out. Entries are exact-match `host:port` — no globs, and the shapes that would widen the boundary (portless, wildcard) are startup errors, as is an unbracketed IPv6 literal, which would *empty* it (`2001:db8::1` reads its last colon as the port separator and can never match — write `[2001:db8::1]:443`); a destination naming no port is judged at its scheme's (80 http, 443 otherwise). A destination off the wire is never trusted to parse: a malformed authority is denied by name, never a panic. When the config field is empty, the binary falls back to the `MOCA_EGRESS_ALLOW` env var (comma-separated `host:port`) — the channel the MOCA conformance harness drives the target with. Both empty keeps egress exactly as it was. **The allowlist is checked BEFORE `skip_hosts`** — when on, it is the outer boundary, and a skip pattern cannot exempt an off-list host. Example:
```yaml
listener:
egress_allow_hosts:
- "github-tool-mcp:8080"
- "api.anthropic.com:443"
```
Env delivery instead: `MOCA_EGRESS_ALLOW=github-tool-mcp:8080,api.anthropic.com:443`. Any change requires a pod restart (same rule as other `listener.*` fields). Each refusal leaves a `phase: "denied"` session row carrying an `egress-allowlist` invocation naming the host. **Scope: the forward proxy only** — "outer boundary" is outer with respect to `skip_hosts`, not with respect to every exit from the pod. **Egress that iptables REDIRECTs to `listener.transparent_proxy_addr` (proxy-sidecar enforce-redirect mode) is not filtered by it** — that listener runs the shared pipeline but dials its recovered destination without consulting the list — and the extproc listener (envoy-sidecar) has no wiring at all: only `cmd/cortex` builds a matcher, onto the forward proxy server. Note the contrast with `skip_hosts`, which the transparent listener also does not consult: there the omission hardens the boundary (a skip cannot self-exempt), here it leaves a way around this one. That matches the issue's scope (MOCA shape A is the proxy-sidecar forward proxy, CONNECT plus absolute-form), but a deployment that also captures traffic by iptables REDIRECT, or runs envoy-sidecar, must hold its egress somewhere else too: deny-by-default here is not deny-by-default for the pod. The sibling `listener.egress_guard` (#1539) is a different boundary and composes — this list judges the **name** before DNS, the guard judges the **address** it resolved to at the dial, so an allow-list entry is not a permit for the guard.

- **Backstop — intent pin in the eviction policy**: this one only applies when `session.max_events` is set, because with no cap there is no FIFO eviction to survive. Where it is set, and even with `skip_hosts` empty, the session store pins the most-recent inbound A2A request event against that eviction. If the buffer overflows, every other event evicts in normal chronological order; the protected intent stays at its original timestamp, leaving a visible time gap in the timeline. Older intents from earlier turns are NOT pinned — only the latest one — so a multi-turn conversation with huge fan-out can't pile up stale intents and starve the buffer. The pin protects against FIFO eviction only: IBAC's `LastIntent()` survives buffer overflow as long as the session is still alive and an inbound A2A request has landed in it, but can still return nil after explicit deletion, eviction once `max_sessions` is exceeded, a configured `session.ttl` elapsing, or before the first inbound request arrives. The pin is defense-in-depth; reach for `skip_hosts` first when the offending traffic is identifiable infrastructure.

## DCO Sign-Off (Mandatory)
Expand Down
41 changes: 41 additions & 0 deletions cmd/cortex/egressallow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import "testing"

// resolveEgressAllow picks the allow list's source: config wins over the
// MOCA_EGRESS_ALLOW env var, the env var is split on commas with blanks
// dropped, and nothing configured anywhere means the feature is off (nil).
// The env var is the MOCA conformance harness's only channel into the target,
// so the fallback is the contract, not a convenience.
func TestResolveEgressAllow(t *testing.T) {
configured := []string{"a.test:443", "b.test:80"}
if got := resolveEgressAllow(configured, func(string) string {
t.Fatal("config entries must win without consulting the environment")
return ""
}); len(got) != 2 || got[0] != "a.test:443" || got[1] != "b.test:80" {
t.Errorf("config wins: got %v, want the configured entries", got)
}

env := func(name, value string) func(string) string {
return func(k string) string {
if k == envEgressAllow {
return value
}
return ""
}
}

if got := resolveEgressAllow(nil, env(envEgressAllow, " a.test:443 , ,b.test:80 ")); len(got) != 2 ||
got[0] != "a.test:443" || got[1] != "b.test:80" {
t.Errorf("env fallback: got %v, want the two comma-separated entries, trimmed", got)
}
if got := resolveEgressAllow(nil, env(envEgressAllow, " ")); got != nil {
t.Errorf("blank env: got %v, want nil — off, not an empty-but-armed list", got)
}
if got := resolveEgressAllow(nil, env(envEgressAllow, "")); got != nil {
t.Errorf("unset env: got %v, want nil", got)
}
if got := resolveEgressAllow(nil, func(string) string { return "" }); got != nil {
t.Errorf("nothing configured: got %v, want nil", got)
}
}
44 changes: 44 additions & 0 deletions cmd/cortex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (

// Only HTTP listeners are compiled in: no extproc
// (no gRPC, no envoy types).
"github.com/rossoctl/cortex/core/listener/egressallow"
"github.com/rossoctl/cortex/core/listener/egressguard"
"github.com/rossoctl/cortex/core/listener/forwardproxy"
"github.com/rossoctl/cortex/core/listener/reverseproxy"
Expand Down Expand Up @@ -275,6 +276,34 @@ func ledgerDefaultOnValue(cfg *config.Config, configPath string) bool {
return on
}

// envEgressAllow is the MOCA egress contract's delivery channel: the
// conformance harness arms the target's allow list by setting it, with no
// config file involved. The name is that contract's, used verbatim.
const envEgressAllow = "MOCA_EGRESS_ALLOW"

// resolveEgressAllow picks the egress allow list's source: the config
// field when it names entries, else MOCA_EGRESS_ALLOW (comma-separated
// host:port) when that is set, else nothing — the feature off. Config
// wins over the env var so an operator who sets both gets one answer,
// and getenv is a parameter so tests drive it without touching the
// process environment.
func resolveEgressAllow(configured []string, getenv func(string) string) []string {
if len(configured) > 0 {
return configured
}
raw := strings.TrimSpace(getenv(envEgressAllow))
if raw == "" {
return nil
}
var entries []string
for _, e := range strings.Split(raw, ",") {
if e = strings.TrimSpace(e); e != "" {
entries = append(entries, e)
}
}
return entries
}

// closeLedgerOnFatal hands the ledger's open minute back before a fatal exit. Set once, when the
// ledger opens; nil whenever there is no ledger to flush.
//
Expand Down Expand Up @@ -1014,6 +1043,21 @@ func main() {
fatalf("listener.skip_hosts: %v", serr)
}
fpSrv.SkipHosts = skipHosts
// Egress allow list (#1483): deny-by-default destinations for the
// forward proxy — off-list CONNECT targets and absolute-form hosts
// are refused with 403 destination_denied before any resolve or
// dial. Config first; MOCA_EGRESS_ALLOW is the fallback channel
// because it is the one the MOCA conformance harness drives the
// target with. Both empty keeps egress exactly as it was.
if entries := resolveEgressAllow(cfg.Listener.EgressAllowHosts, os.Getenv); len(entries) > 0 {
allow, aerr := egressallow.New(entries)
if aerr != nil {
fatalf("listener.egress_allow_hosts: %v", aerr)
}
fpSrv.EgressAllow = allow
slog.Info("egress allow list on: off-list destinations refused with 403 destination_denied",
"entries", len(entries))
}
fpSrv.TLSBridge = bridge
fpSrv.Shared = sharedStore
// A Responses API WebSocket is answered 426 so Codex falls back to HTTPS at once,
Expand Down
57 changes: 57 additions & 0 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -928,13 +928,70 @@ type ListenerConfig struct {
// consult SkipHosts — that is the hard egress guard and must not
// be self-exemptable via the agent's outbound destination.
//
// When EgressAllowHosts (or MOCA_EGRESS_ALLOW) is non-empty, the
// allowlist is checked FIRST and a skip pattern cannot exempt an
// off-list host: the allowlist is the outer boundary.
//
// Match-all patterns ("*", "**", whitespace-only) and patterns
// containing ":port" are rejected at startup so a single
// misconfigured entry can't silently disable all outbound
// enforcement. Mirrors the bypass-pattern guard added to ibac
// in #496.
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`

// EgressAllowHosts is the forward proxy's deny-by-default destination
// allow list (#1483, MOCA MI1 §8.5): when non-empty, an outbound
// destination not on the list is refused with 403 destination_denied
// naming the host — header X-Moca-Egress-Error, text/plain body
// "destination_denied: <host>\n", the moca egress conformance
// contract's refusal shape — before the plugin pipeline, before DNS
// resolution and before any dial, so an off-list host is answered by
// name rather than left to time out. Applies to CONNECT targets,
// absolute-form http:// hosts, and the target a plugin's redirect
// moved a request to.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
//
// Entries are "host:port", exact match (no globs — a wildcard in a
// security boundary is one typo from "allow everything", so the shapes
// that would widen it are rejected at startup). Hosts compare
// lowercased without a trailing dot; a destination naming no port
// defaults to its scheme's (80 http, 443 otherwise).
//
// Checked BEFORE skip_hosts: when the list is on it is the outer
// boundary, and a skip_hosts entry for an off-list host is refused,
// not honored — bypass must not become an egress bypass.
//
// When this field is empty, the binary falls back to the
// MOCA_EGRESS_ALLOW environment variable (comma-separated host:port
// entries) — the channel the MOCA conformance harness drives the
// target with. Both empty means the feature is off and outbound
// behavior is unchanged.
//
// SCOPE — this gates the FORWARD PROXY's paths only, and "outer
// boundary" above means outer with respect to skip_hosts, not with
// respect to every way a byte can leave the pod. Not covered:
//
// - the transparent listener (listener.transparent_proxy_addr,
// proxy-sidecar enforce-redirect mode): egress that iptables
// REDIRECTs there is NOT filtered by this list.
// HandleTransparentConn runs the shared pipeline but dials the
// recovered destination without consulting it. Note the contrast
// with SkipHosts above, which that listener also does not consult
// — there the omission hardens the boundary, here it leaves a way
// around this one.
// - the extproc listener (envoy-sidecar mode): no wiring at all —
// only cmd/cortex builds a Matcher, and only onto the forward
// proxy server.
//
// MOCA shape A is the proxy-sidecar forward proxy and #1483 names
// CONNECT plus absolute-form only, so that is the scope this field
// claims. A deployment that also captures traffic by iptables
// REDIRECT, or runs envoy-sidecar, needs its egress held somewhere
// else too — deny-by-default here is not deny-by-default for the pod.
//
// A listener.* field: changing it requires a restart, like every other
// listener knob.
EgressAllowHosts []string `yaml:"egress_allow_hosts,omitempty" json:"egress_allow_hosts,omitempty"`

// DeclineResponsesWebSockets: whether the forward proxy answers a WebSocket
// upgrade on an OpenAI Responses API path (one ending /responses, on chatgpt.com
// or api.openai.com) with 426 itself, instead of forwarding the request with its Upgrade stripped. nil means
Expand Down
Loading
Loading