Documentation
¶
Overview ¶
Package proxy implements the L2 per-workspace egress sidecar (wardyn-proxy): an HTTP forward proxy that enforces the internal/egress decision model (default-deny domain allowlist, method rules, first-use approval), streams decision logs, and injects credentials proxy-side.
SECURITY INVARIANTS (mirror ARCHITECTURE.md and internal/egress):
- Default deny: an empty allowlist allows nothing.
- DeniedDomains always beats AllowedDomains.
- Private/loopback/link-local/metadata IP ranges are unconditionally denied BEFORE any dial, regardless of policy (DNS-rebinding / SSRF guard). The vetted IP is dialed explicitly — the transport never re-resolves the hostname (no TOCTOU).
- Fail closed on every error path.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func NewBuiltinEvaluator ¶
func NewBuiltinEvaluator(spec types.RunPolicySpec) egress.Evaluator
NewBuiltinEvaluator returns the builtin egress.Evaluator for a compiled spec. It is exported so the conformance suite (and operators wiring the builtin explicitly) can construct it.
func ValidDomainEntry ¶ added in v0.4.0
ValidDomainEntry reports whether d is an allowlist/denylist entry the matcher above can ever match, and is the ONE shape check every operator-supplied policy ingest point runs (validatePolicySpec, internal/api). classifyDomain accepts anything — a mid-label pattern like "oidc.*.amazonaws.com" compiles to an exact hostname no real request can equal, so it silently protects nothing. Operator input fails closed instead: reject the dead entry at write time rather than ship a policy the operator believes is guarding them.
Valid: a bare exact host ("api.anthropic.com"), a leading-"*." wildcard ("*.example.com"), and either with a valid ":port" qualifier.
Types ¶
type Config ¶
type Config struct {
// RunID is the governed run this sidecar serves.
RunID uuid.UUID `json:"run_id"`
// ControlPlaneURL is the base URL of wardynd (e.g. "http://wardynd:8080").
ControlPlaneURL string `json:"control_plane_url"`
// RunToken authenticates internal calls (Authorization: Bearer <token>).
RunToken string `json:"run_token"`
// Policy is the compiled egress allowlist / method rules / first-use flag.
Policy types.RunPolicySpec `json:"policy"`
// Injection rules drive proxy-side credential injection (plain HTTP only).
// Each rule carries the grant_id the secret is minted from at startup.
Injection []InjectionConfig `json:"injection,omitempty"`
// Listen is the proxy listen address (default ":3128").
Listen string `json:"listen,omitempty"`
// DecisionBufferSize caps the async decision-log buffer (default 1024).
DecisionBufferSize int `json:"decision_buffer_size,omitempty"`
// MITMCACertPEM / MITMCAKeyPEM are the OPTIONAL TLS-MITM certificate authority
// (PEM). When both are set AND content inspection is enabled, the proxy
// TLS-terminates opaque CONNECT tunnels to known LLM hosts (Anthropic/OpenAI)
// to inspect the subscription-OAuth path. The CA PRIVATE KEY stays in proxy
// memory only; the sandbox trusts only the CA's public cert (delivered to its
// trust store by the driver). Empty => opaque passthrough (no MITM).
MITMCACertPEM string `json:"mitm_ca_cert_pem,omitempty"`
MITMCAKeyPEM string `json:"mitm_ca_key_pem,omitempty"`
// MITMHosts are OPERATOR-CONFIGURED corp artifact hosts (exact hostnames) this
// proxy is permitted to TLS-MITM in addition to the built-in LLM hosts, so a
// corporate registry token can be injected on the wire. See isMITMHost's trust
// boundary: this is a tight per-host operator allowlist compiled at dispatch
// from the site-config artifact overrides — NOT attacker-reachable (the sandbox
// cannot set it), NEVER a wildcard, and only meaningful with a paired injection
// rule that supplies the operator's OWN token. Empty => LLM hosts only.
MITMHosts []string `json:"mitm_hosts,omitempty"`
// GitGrants is the git-broker per-repo allowlist: canonical "<org>/<repo>" ->
// the github_token grant id to mint from, backing the /wardyn/gh/ route so the
// sandbox reaches only its granted repos (never all of github.com) and the token
// stays proxy-side. Compiled at dispatch from the run's github grants. Empty =>
// the git-broker route always 403s (no repo brokered).
GitGrants map[string]uuid.UUID `json:"git_grants,omitempty"`
// MITMLLM reports whether TLS-MITM of the BUILT-IN LLM hosts (Anthropic/OpenAI)
// is actually intended for this run — i.e. subscription credential injection OR
// intercept_tls content inspection. Dispatch also mints the per-run CA for
// artifact token injection (MITMHosts), so "a CA exists" no longer implies "LLM
// MITM was wanted"; without this flag an artifact-only run would TLS-terminate a
// direct CONNECT to Anthropic/OpenAI it was never asked to. Empty/false => the
// LLM hosts stay opaque passthrough even when a CA is present for artifact hosts.
MITMLLM bool `json:"mitm_llm,omitempty"`
// HoldForReviewTimeoutSec bounds how long the proxy holds a connection open in
// wait_for_review mode before failing closed (403, with the approval left
// pending so a later retry can still be approved). Deliberately under common
// client timeouts. <=0 uses the default (30s).
HoldForReviewTimeoutSec int `json:"hold_for_review_timeout_sec,omitempty"`
// MaxConcurrentHolds caps simultaneous wait_for_review holds for this run; past
// the cap a new unknown host falls back to fail-fast pending (deny_with_review)
// instead of parking another goroutine — a bound on held-connection resource
// exhaustion. <=0 uses the default (16).
MaxConcurrentHolds int `json:"max_concurrent_holds,omitempty"`
// UpstreamProxyURL is the OPTIONAL corporate parent/upstream proxy that this
// sidecar chains its egress through (http[s]://[user:pass@]host[:port]). In a
// locked-down corporate network the sandbox host has NO direct internet route
// — the org's HTTP CONNECT proxy is the only way out (and is frequently a
// PRIVATE address). When set, forward-egress dials are issued as
// CONNECT <real-host> to this proxy; control-plane calls to wardynd never
// traverse it. Any embedded credential is held proxy-memory-only (like
// RunToken) and masked from all decision-log/stdout output. Empty => direct
// dial (backward-compatible).
//
// TODO(site-config): sourced operator-wide from the persisted site-config
// (upstream_proxy_secret_ref) once that store lands; today it is threaded
// from the run's ProxyConfig at dispatch.
UpstreamProxyURL string `json:"upstream_proxy_url,omitempty"`
}
Config is the wardyn-proxy sidecar configuration, loaded from a JSON file via the -config flag. The run token authenticates the sidecar to the control plane's internal endpoints (verified via identity.Provider.Verify with audience "wardyn-internal"); it is NOT a secret usable outside the platform. No third-party secrets ever appear here — injected credentials are minted at startup from the broker and held only in proxy memory.
func LoadConfig ¶
LoadConfig reads and validates a Config from path.
func LoadConfigBytes ¶
LoadConfigBytes parses and validates a Config from raw JSON. Used by the sidecar's env-var config path (WARDYN_PROXY_CONFIG_JSON), which is how the docker driver delivers the run's policy without managing host files.
type IPGuardResult ¶
type IPGuardResult struct {
// IP is the single vetted address to dial (no further DNS resolution).
IP net.IP
// Denied is true when no usable, policy-safe address exists.
Denied bool
// Reason explains a denial (for the decision log rule_source).
Reason string
}
IPGuardResult records the outcome of resolving + vetting a target host.
func VetHost ¶
func VetHost(host string, res resolver) IPGuardResult
VetHost resolves host and returns the first address that is NOT in a blocked range. If host is already a literal IP, it is vetted directly. On any failure or if every address is blocked, the result is Denied (fail closed). The returned IP MUST be the one dialed — callers must not re-resolve the hostname (TOCTOU / DNS-rebinding guard).
type InjectionConfig ¶
type InjectionConfig struct {
egress.InjectionRule
// GrantID identifies the credential_grant to mint the secret value from
// at startup via POST /api/v1/internal/credentials/mint.
GrantID uuid.UUID `json:"grant_id"`
}
InjectionConfig pairs an egress.InjectionRule with the credential grant the proxy mints from at startup. The minted secret lives ONLY in proxy memory: it is never exposed to the sandbox (no env, no disk, no args). CONNECT tunnels cannot be injected into — the proxy has hostname-only visibility on TLS and never sees the encrypted request headers — so injection applies to plain-HTTP requests only.
type Options ¶
type Options struct {
RunID uuid.UUID
Policy *Policy
// Evaluator overrides the pluggable host/method policy-verdict engine. Nil =>
// the builtin evaluator wrapping Policy (default; unchanged behavior).
Evaluator egress.Evaluator
Approval *approvalClient
Injector *injector
Sink *decisionSink
Resolver resolver
// Scanner is the optional outbound content-inspection engine (nil == off).
Scanner *contentscan.Engine
// CA is the optional TLS-MITM certificate authority (nil == opaque CONNECT).
CA *certAuthority
// MITMHosts are operator-configured corp artifact hosts eligible for TLS-MITM
// beyond the built-in LLM hosts (exact hostnames). See Proxy.mitmHosts.
MITMHosts []string
// MITMLLM gates TLS-MITM of the built-in LLM hosts on actual intent. See
// Proxy.mitmLLM.
MITMLLM bool
// GitGrants is the git-broker per-repo allowlist ("<org>/<repo>" -> grant id)
// backing the /wardyn/gh/ route. See Proxy.gitGrants.
GitGrants map[string]uuid.UUID
// ControlPlaneURL and RunToken back the local brokered routes. The run
// token is injected only toward the control plane and never reaches the
// sandbox or any LLM upstream.
ControlPlaneURL string
RunToken *tokenSource
// Upstream is the OPTIONAL corporate parent proxy (parsed form). Nil ==
// direct dial. NewServer parses it from Config.UpstreamProxyURL; tests may
// build one via parseUpstreamProxy.
Upstream *upstreamProxy
// Dial overrides the connection dialer (tests). Production leaves it nil
// and a net.Dialer is used.
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
// TLSClientConfig overrides the forwarding transport's TLS config. Tests
// use this to trust an httptest TLS server standing in for an HTTPS LLM
// upstream; production leaves it nil (system roots, ServerName from URL).
TLSClientConfig *tls.Config
Now func() time.Time
}
Options configures a Proxy. Nil fields fall back to production defaults.
type Policy ¶
type Policy struct {
// contains filtered or unexported fields
}
Policy is a compiled, immutable view of types.RunPolicySpec optimized for per-request evaluation.
func CompilePolicy ¶
func CompilePolicy(spec types.RunPolicySpec) *Policy
CompilePolicy builds a Policy from a RunPolicySpec. Domains are normalized to lowercase; trailing dots are stripped. Methods are uppercased.
func (*Policy) AllowedExactHost ¶
AllowedExactHost reports whether host is allowed via an EXACT allowlist entry (not a wildcard, not approval). Credential injection requires this stricter match so an injection rule can never widen egress nor leak a secret to a wildcard-matched host.
func (*Policy) FirstUseMode ¶
func (p *Policy) FirstUseMode() types.FirstUseMode
FirstUseMode reports how unknown domains are handled (always_deny / deny_with_review / wait_for_review), normalized (never empty).
type Proxy ¶
type Proxy struct {
// contains filtered or unexported fields
}
Proxy is the L2 forward proxy. It serves both absolute-URI plain HTTP requests and CONNECT tunnels for TLS (hostname-only visibility; no interception in v0). All egress flows through the decision pipeline:
policy (deny>allow, default-deny) -> first-use approval (if unknown && first_use_approval) -> method check -> IP vetting (block private/ loopback/link-local/metadata) -> dial the vetted IP explicitly.
Every decision emits an egress.DecisionLog (async to the control plane, mirrored to stdout).
func (*Proxy) ServeHTTP ¶
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP routes between CONNECT tunneling, brokered LOCAL routes, and plain-HTTP forwarding.
SECURITY: the local brokered routes (/wardyn/...) are reachable ONLY via origin-form requests addressed to the proxy listener itself — i.e. the request-target is path-only (r.URL.Host == ""). An absolute-URI forward request for http://wardyn-proxy:3128/wardyn/v1/... carries a non-empty r.URL.Host and is handled by the normal forward-proxy path, where the proxy host is not allowlisted and is denied by policy. CONNECT is never a local route. This makes the run token unreachable by anything the sandbox can route through the forward proxy.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server bundles a Proxy with its http.Server and async decision sink for lifecycle management.
func NewServer ¶
func NewServer(ctx context.Context, cfg *Config, client *http.Client, stdout io.Writer) (*Server, error)
NewServer wires a Proxy from a validated Config and dependencies. It mints injection credentials once (fail-closed on error) before returning.
func (*Server) ListenAndServe ¶
ListenAndServe starts serving and blocks until the server stops.