proxy

package
v0.7.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 42 Imported by: 0

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). Under a corporate upstream the PIN is relaxed (the corp proxy resolves and dials, so the target is sent by name) but the GUARD is not: egressTarget resolves for the guard and denies a name that answers into a blocked range. Its two stated residuals are a name this proxy cannot resolve at all, which is forwarded to the corp proxy's own egress controls, and — because the target is sent by name — a name that answers differently to the two resolvers, which the guard binds only at check time (see egressTarget, THREAT-MODEL.md §4.2).
  • Fail closed on every error path.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BranchNSEnforced added in v0.5.0

func BranchNSEnforced() bool

BranchNSEnforced reports whether push branch-namespace confinement is ON for THIS proxy process (env, like the WARDYN_LLM_SCAN kill-switch). Exported so cmd/wardyn-proxy can state the posture ONCE at boot: this is the only default-ON control in the git-broker path, and =false used to produce no signal anywhere — no boot line, no distinct decision row — while the per-mint branch_namespace metadata is written identically either way, so a run on an opted-out proxy read exactly like a confined one.

ON by default. The reason it could not be is now closed: agent-run checks every cloned repo out onto `wardyn/$WARDYN_RUN_ID/work` and sets push.default=current (name_run_branch in deploy/images/common/agent-run-lib.sh), so a stock run is already inside its namespace and complies without the operator pinning the convention in task text. Set WARDYN_GIT_BROKER_ENFORCE_BRANCH_NS=false to opt back out (e.g. an image whose agent-run predates the run branch).

SCOPE, honestly: THIS switch binds the BROKERED GITHUB path only — handleGitBroker, not its git_pat sibling. It sees git-receive-pack because the sandbox->proxy hop is cleartext on the proxy's own route, and the installation token itself is repo-scoped but not ref-scoped.

An SSH push is still an opaque tunnel no pkt-line parser can read. A PAT push is NOT, and saying so was the stale half of this comment: the never-resident git_pat lane (default ON since 0.7) removes that tunnel by design — the proxy terminates the request itself and holds the same cleartext command section (see pat_broker.go). Since 0.7.2 the parser IS wired in there, through the same confinePush step, but behind its OWN switch and DEFAULT OFF (WARDYN_GIT_PAT_BROKER_ENFORCE_BRANCH_NS, PATBranchNSEnforced) — the widening stayed deliberate rather than implied, because a PAT carries whatever scope the operator issued and Wardyn cannot narrow it, so on forges whose push ref conventions are not GitHub's the namespace is a convention Wardyn asks for, not a bound it can enforce. The two defaults are therefore opposite ON PURPOSE, and each switch says only what its own lane does. This is not a hole in THIS lane's confinement: api.validateGrantLaneExclusivity already refuses a git_pat grant for the same forge as a github_token grant, so a brokered repo has no PAT path beside it. Dispatch removes AND denies the broker-managed GitHub host names on a brokered run — plus that forge's ssh.<forge> endpoint, so a co-granted ssh_key leaves no push path beside the brokered one (confineGitBrokerEgress in internal/api/runs_dispatch_gitbroker.go, and validateGrantLaneExclusivity refuses the combination at policy-write), and the forge's ssh_key AND git_pat grants are withheld from the sandbox entirely at dispatch (dropBrokeredGrants) so no unusable credential is left resident. With one standing caveat, the same one docs/POLICIES.md gives the four HTTPS denies: those are NAME-based denies and a name-based deny does not bind an IP LITERAL (under allow_all_egress a CONNECT to 140.82.114.4:22 is still allowed). The brokered route is the only CONVENIENT route — the one git itself takes, since a clone URL carries a name — not the only conceivable one.

Loud parse: an unrecognized value fails CLOSED (enforce + error log) rather than silently disabling a security control on a typo.

func BranchNSPrefix added in v0.5.0

func BranchNSPrefix(runID uuid.UUID) string

BranchNSPrefix is the ONLY ref prefix a governed run may push to: the branch namespace the broker records in github_token grant metadata, `wardyn/<run-id>/*`, rooted under refs/heads/. It is a pure function of the run id (which this proxy already holds), so enforcement needs no extra plumbing — but it MUST stay in lockstep with internal/broker.branchNamespaceFormat; that constant's comment points back here and broker's TestBranchNamespaceLockstepWithProxy fails on drift (the reason this is exported). Everything else (other branches, the default branch, tags, refs/pull/*, refs/notes/*) is outside the namespace.

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 NoProxyCovers added in v0.7.10

func NoProxyCovers(entries []string, host string) bool

NoProxyCovers reports whether entries — an UNCOMPILED SiteConfig.UpstreamProxyNoProxy list, exactly as an admin wrote it — would bypass a dial to host, the same routing question bypassUpstream answers for a live Proxy's already-compiled list. Exposed as a pure function, not a *Proxy method, so a caller with no running Proxy (cmd/wardynd's boot-time advisory for a gateway host with no covering bypass entry) can ask it. It compiles entries via compileNoProxy — the same compiler bypassUpstream's caller (NewServer) feeds p.noProxy from — so a typo'd entry that would be silently dropped there is silently absent here too, never a false cover.

func PATBranchNSEnforced added in v0.7.2

func PATBranchNSEnforced() bool

PATBranchNSEnforced reports whether push branch-namespace confinement is ON for the git_pat lane of THIS proxy process. OFF unless the operator opts in with WARDYN_GIT_PAT_BROKER_ENFORCE_BRANCH_NS.

It is a SECOND switch, with the OPPOSITE default, rather than a widening of BranchNSEnforced, and the asymmetry is the decision itself: a GitHub App installation token is Wardyn's own mint, so a namespace is a bound Wardyn put on a credential it issued, and agent-run already checks the work tree out onto wardyn/<run-id>/work. A PAT is the operator's, carrying whatever scope they issued it with and used against forges whose push-ref conventions are not GitHub's — so the namespace there is a convention imposed on a credential Wardyn cannot narrow, and turning it on by default would break pushes that were never promised to be confined.

Once opted IN, the parse is the same loud, fail-closed one BranchNSEnforced uses: garbage ENFORCES and logs, because a typo must never silently undo a control the operator deliberately turned on.

func ValidDomainEntry added in v0.4.0

func ValidDomainEntry(d string) error

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.

func ValidNoProxyEntry added in v0.7.0

func ValidNoProxyEntry(raw string) bool

ValidNoProxyEntry reports whether one SiteConfig.UpstreamProxyNoProxy entry is a CIDR or a host/domain suffix this proxy will actually honour. Exported because the write-time validator in internal/api must refuse exactly what compileNoProxy would drop — a typo'd entry that silently means "still proxied" is the failure mode the field exists to end.

func ValidUpstreamProxyURL added in v0.7.0

func ValidUpstreamProxyURL(raw string) error

ValidUpstreamProxyURL reports whether raw is an upstream/corp proxy URL this sidecar's own loader will ACCEPT — the write-time delegate for UpstreamProxyURL that ValidNoProxyEntry already is for UpstreamProxyNoProxy, and for the same reason: a second copy of the rule in the API package is a dual matcher over one operator-authored field, and this one drifted. The control plane checked only url.Parse + the http scheme, so a port of `0` or `99999` was persisted with 200 OK and then failed applyDefaultsAndValidate below at container start, which cmd/wardyn-proxy/main.go turns into os.Exit(1) — the egress sidecar of EVERY dispatched run, killed by a value the write path said was fine.

It IS parseUpstreamProxy — the very rule applyDefaultsAndValidate runs below — exported so the control plane can apply THE SAME rule at write time (validateSiteConfig) and at dispatch (loadableUpstreamProxyURL, internal/api/runs_bedrock.go) instead of keeping a second, narrower copy.

The error is returned rather than a bool so the caller can name the real cause; parseUpstreamProxy never echoes the raw URL (it may carry user:pass credentials), so the message is always safe to surface. The empty string is valid (upstream disabled), matching parseUpstreamProxy.

Types

type ADOGrant added in v0.7.10

type ADOGrant struct {
	Organization string
	Capabilities []adoscope.Capability
}

ADOGrant is what the run's Azure DevOps grant holds for one host: the ONE organisation the row pins and the capabilities the run was granted.

type ADOGrantConfig added in v0.7.10

type ADOGrantConfig struct {
	Organization string                `json:"organization"`
	Capabilities []adoscope.Capability `json:"capabilities"`
	Hosts        []string              `json:"hosts"`
}

ADOGrantConfig is the run's per-person Azure DevOps grant as dispatch writes it into this sidecar's configuration: the organisation the provider row pins, the capabilities the run was granted, and the EXACT hosts (bare, lower-case) the grant covers. The sidecar is a separate process, so this — not a live call — is how the REST gate (ado_gate.go) learns what to hold each request to.

type ADOGrantSource added in v0.7.10

type ADOGrantSource interface {
	ADOGrantFor(host string) (ADOGrant, bool)
}

ADOGrantSource answers, for a host, the run's Azure DevOps grant. ok=false means the host is not covered and the gate stands aside.

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's internal TLS listener (e.g.
	// "https://wardynd:8443"). http:// is refused unless the host is loopback
	// (hoptls.CheckURL).
	ControlPlaneURL string `json:"control_plane_url"`
	// ControlPlaneCAPEM is wardynd's internal CA (internal/hoptls), the ONLY
	// root this sidecar trusts for control-plane calls. Required with an
	// https ControlPlaneURL.
	ControlPlaneCAPEM string `json:"control_plane_ca_pem,omitempty"`
	// 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"`
	// PATGrants is the git_pat broker's per-HOST allowlist: canonical lower-case
	// host -> the git_pat grant id to mint from, backing the /wardyn/git/ route.
	//
	// It is the non-GitHub half of the same idea as GitGrants, and it exists to
	// close an asymmetry rather than to add a feature: a github_token never
	// enters the sandbox, while a git_pat for GitLab or Azure DevOps was handed
	// to the in-sandbox credential helper and was therefore resident for the
	// life of the run. The grant kind's own doc calls that "the OPPOSITE of
	// api_key", because git-over-HTTPS is an opaque CONNECT tunnel the proxy
	// cannot inject Basic-auth into.
	//
	// This route removes the tunnel: agent-run rewrites those hosts to a
	// PLAIN-HTTP broker path, so the proxy terminates the request, mints
	// server-side and injects the credential itself. The PAT never reaches the
	// sandbox.
	//
	// Per-HOST rather than per-repo, deliberately: unlike a GitHub App
	// installation token, a PAT carries whatever scope the operator issued it
	// with and Wardyn cannot narrow it — so a per-repo key here would imply a
	// confinement the credential does not have. Empty => the route always 403s.
	PATGrants map[string]PATGrant `json:"pat_grants,omitempty"`
	// ADOGrants is the run's per-person Azure DevOps grant, which drives the
	// REST gate (ado_gate.go, ado_grants.go). Empty == the gate is off.
	ADOGrants []ADOGrantConfig `json:"ado_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"`
	// 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).
	//
	// SOURCE vs TRANSPORT: the only source is the persisted site-config
	// (upstream_proxy_secret_ref, admin-authored via PUT /api/v1/site-config).
	// It is resolved per dispatch by resolveRunUpstreamProxy
	// (internal/api/runs_dispatch.go) through resolveUpstreamProxyURL (which
	// lives in internal/api/runs_bedrock.go), audited as
	// run.upstream_proxy.resolve on resolve success/failure, and merely
	// TRANSPORTED here by the run's ProxyConfig (WARDYN_PROXY_CONFIG_JSON,
	// internal/runner/docker/driver.go).
	UpstreamProxyURL string `json:"upstream_proxy_url,omitempty"`
	// UpstreamProxyNoProxy is the upstream's BYPASS list
	// (SiteConfig.UpstreamProxyNoProxy, forwarded verbatim): host/domain
	// suffixes and CIDRs this sidecar dials DIRECTLY instead of CONNECTing
	// through UpstreamProxyURL. It is the operator-hop equivalent of the
	// NO_PROXY the sandbox already honours internally. This sidecar vets the
	// name on the upstream branch too (egressTarget): an endpoint that resolves
	// into blocked space is denied HERE (builtin:private-ip), and one that does
	// not is handed to a corporate forward proxy that will not CONNECT to an
	// internal address; either way the bypass is what moves the dial local.
	//
	// It is a ROUTING list only: a bypassed dial falls through to the same
	// unconditional private/reserved-IP guard an unproxied dial does, so it
	// never makes a private address reachable on its own (InternalHosts above
	// is the one lift, and the two compose), and it grants no policy allow.
	// Control-plane authored, same trust boundary as TrustedCAPEM/
	// InternalHosts — the sandbox cannot set it. Empty (the default) => every
	// forward dial chains through the upstream, byte-identical to today.
	UpstreamProxyNoProxy []string `json:"upstream_proxy_no_proxy,omitempty"`
	// TrustedCAPEM is the OPERATOR's corporate CA bundle (WARDYN_TRUSTED_CA_FILE,
	// wardynd's Config.TrustedCAPEM), forwarded verbatim per run so THIS
	// sidecar's forward/egress transport additionally trusts a corporate
	// TLS-inspecting middlebox on the path to the real upstream. Never the
	// control-plane transport, which trusts ControlPlaneCAPEM alone. Control-plane
	// authored, same trust boundary as MITMCACertPEM/MITMCAKeyPEM above — the
	// sandbox cannot set it. Empty (the default) => system roots only,
	// byte-identical to today.
	TrustedCAPEM string `json:"trusted_ca_pem,omitempty"`
	// InternalHosts are the operator-declared internal hostnames
	// (SiteConfig.InternalHosts, forwarded verbatim) eligible for the
	// private-IP-guard lift in Proxy.vetHost — an admin-declared exception for a
	// specific in-cluster/CGNAT service name, never attacker-reachable (the
	// sandbox cannot set this). Empty (the default) => no lift, byte-identical
	// to today.
	InternalHosts []types.InternalHost `json:"internal_hosts,omitempty"`
	// LLMUpstreams maps a public vendor host ("api.anthropic.com" /
	// "api.openai.com") to an operator-configured internal gateway base URL
	// (api.Config.LLMGateways, forwarded verbatim; WARDYN_ANTHROPIC_BASE_URL /
	// WARDYN_OPENAI_BASE_URL) that the /wardyn/llm/* brokered routes dial
	// instead of the vendor host. Control-plane-authored, same trust boundary
	// as TrustedCAPEM/InternalHosts above — the sandbox cannot set this. Empty
	// (the default) => every brokered LLM route dials the vendor host,
	// byte-identical to today.
	LLMUpstreams map[string]string `json:"llm_upstreams,omitempty"`
	// LLMUnavailableDetail is what the brokered-LLM 404 says about WHY no
	// credential is behind this run's LLM route, composed by the control plane at
	// dispatch (internal/api's llmUnavailableDetail) because only it knows the
	// deployment's model-provider posture — the sidecar knows only that no
	// injection rule matched. Control-plane-authored, same trust boundary as
	// LLMUpstreams above; the sandbox cannot set it. Empty (the default) => the
	// route's own generic detail, plus the below-policy clause either way
	// (proxyLLMRequest).
	LLMUnavailableDetail string `json:"llm_unavailable_detail,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

func LoadConfig(path string) (*Config, error)

LoadConfig reads and validates a Config from path.

func LoadConfigBytes

func LoadConfigBytes(b []byte) (*Config, error)

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.

The decode is STRICT (DisallowUnknownFields), matching the decodeStrict posture the control plane's own write paths already use.

TRUST BOUNDARY (F029 — read before relaxing): the sidecar image is pinned by the OPERATOR, independently of wardynd (WARDYN_PROXY_IMAGE, k8s.proxyImage, and the shipped desktop examples pin it by DIGEST), so a config written by a NEWER control plane routinely meets an OLDER proxy binary. A lenient json.Unmarshal accepted such a config with err == nil and silently discarded every key the old binary did not know — and the keys this release added are exactly the ones a private-endpoint estate depends on (upstream_proxy_no_proxy, trusted_ca_pem, internal_hosts, llm_upstreams, pat_grants). The failure mode was therefore: the corp CA never added, the bypass list inert, the internal-host lift never firing — an operator's routing document half-honoured, with no error, no warning and no version handshake anywhere. A key this binary cannot honour must fail the sidecar's startup loudly instead of being dropped on the floor.

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

	// Lifted is true when an address that isBlockedIP would otherwise deny was
	// admitted only because the caller's lift predicate (vetHostLift) accepted
	// it — i.e. an operator-declared internal host (SiteConfig.InternalHosts).
	// Never true for VetHost (which always calls vetHostLift with a nil lift).
	Lifted bool
	// Unresolved distinguishes a denial that means "this proxy could not learn
	// the addresses at all" (local DNS failed, or answered with nothing) from
	// one that means "an address is blocked". egressTarget's corp-upstream
	// branch reads it to EXCUSE the case: under an operator upstream the sandbox
	// host frequently CANNOT resolve external names, which is the whole reason
	// that branch exists, so a resolve failure there must not become a denial —
	// while a name that DOES resolve into blocked space must be, which it was
	// not before. egressTarget's direct-dial branch reads it to ATTRIBUTE the
	// case (errHostUnresolved -> builtin:resolve-failed): there it still denies,
	// but it is a DNS fault and not the address-range guard, and the two have
	// opposite fixes. Every other caller treats Denied as Denied.
	Unresolved bool
	// contains filtered or unexported fields
}

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).

VetHost is vetHostLift with no lift predicate — nothing in blockPrivate is ever admitted. Kept as the exported, unconditional guard so every existing caller/test keeps today's behavior; see vetHostLift for the internal-host exception (Proxy.vetHost).

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
	// PATGrants is the git_pat broker's per-HOST allowlist backing /wardyn/git/.
	// See Config.PATGrants and pat_broker.go for why it is per-host rather than
	// per-repo.
	PATGrants map[string]PATGrant
	// ADOGrants backs the Azure DevOps REST gate (ado_gate.go). Nil == off.
	ADOGrants ADOGrantSource
	// 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
	// UpstreamNoProxy is the operator's upstream BYPASS list
	// (SiteConfig.UpstreamProxyNoProxy, forwarded verbatim): host/domain
	// suffixes and CIDRs dialed DIRECTLY rather than CONNECTed through
	// Upstream. Control-plane-authored, same trust boundary as InternalHosts —
	// the sandbox cannot set it. Empty (the default) => no bypass. Ignored
	// entirely when Upstream is nil. See Proxy.noProxy / Proxy.bypassUpstream.
	UpstreamNoProxy []string
	// InternalHosts are the operator-declared internal hostnames (site-config,
	// CONTROL-PLANE-authored — the sandbox cannot set this) eligible for
	// vetHost's private-IP-guard lift. Empty == no lift (byte-identical to
	// before this field existed). See Proxy.internalHosts.
	InternalHosts []types.InternalHost
	// LocalSubnets are the proxy's own interface subnets (net.InterfaceAddrs,
	// captured once by NewServer before constructing Options). See
	// Proxy.localSubnets.
	LocalSubnets []*net.IPNet
	// ControlPlaneIPs are every address this run's wardynd resolves to,
	// resolved once by NewServer before constructing Options. See
	// Proxy.controlPlaneIPs.
	ControlPlaneIPs []net.IP
	// ExclusionUnknown reports that NewServer's startup capture of LocalSubnets
	// or ControlPlaneIPs failed, so the own-subnet/control-plane clamp must
	// refuse every lift/trust rather than silently permit them. See
	// Proxy.exclusionUnknown.
	ExclusionUnknown bool
	// LLMUpstreams maps a public vendor host to an operator-configured internal
	// gateway base URL (Config.LLMUpstreams, forwarded verbatim). Empty == every
	// brokered LLM route dials the vendor host. See Proxy.llmUpstreams.
	LLMUpstreams map[string]string
	// LLMUnavailableDetail is the control-plane's reason the brokered-LLM 404
	// gives when no credential is behind the route (Config.LLMUnavailableDetail,
	// forwarded verbatim). Empty == the generic route sentence. See llm404Detail.
	LLMUnavailableDetail string
	// 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 is the EGRESS transport's TLS config. Production sets it
	// from Config.TrustedCAPEM (system roots plus the operator's corporate CA
	// bundle — see NewServer); nil means system roots alone. Tests use it to
	// trust an httptest TLS server standing in for an HTTPS upstream.
	TLSClientConfig *tls.Config
	// ControlTLS pins the control-plane transport to wardynd's internal CA
	// (hoptls.ClientConfig). nil pins an EMPTY pool: https fails closed.
	ControlTLS *tls.Config
	Now        func() time.Time
}

Options configures a Proxy. Nil fields fall back to production defaults.

type PATGrant added in v0.7.0

type PATGrant struct {
	GrantID  uuid.UUID `json:"grant_id"`
	Username string    `json:"username,omitempty"`
}

PATGrant is one host's git_pat brokering: which grant to mint from, and the git username that host expects alongside the PAT (Azure DevOps wants "pat", GitLab wants "oauth2", and an operator may override either).

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) AllowedBareExactHost added in v0.7.4

func (p *Policy) AllowedBareExactHost(host string) bool

AllowedBareExactHost is the STRICTER half of the same question: did the operator name this exact host in writing WITHOUT qualifying a port — the "silent about the port" entry the cleartext port-80 injection arm has always been written under (W6-S3).

AllowedExactHost answers "may a credential bind to this host at all", and since B10-F1 a port-qualified-only entry answers yes. That is right for the BINDING — the operator named the host — and wrong for the one caller that has to read an UNAUTHORED port as consent: injectableTransport's port-80 arm, whose whole justification is that a bare entry says nothing about the port, so port 80 is the default port of a plaintext connector rather than the sandbox choosing a transport. An operator who wrote "vendor.example:8443" DID state a port; reading their silence about port 80 as permission inverts it. So that arm asks this, and every other port asks AuthoredPortFor.

func (*Policy) AllowedExactHost

func (p *Policy) AllowedExactHost(host string) bool

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.

PORT-LESS BY CONTRACT, and it consults allowedExactAnyPort for that reason (B10-F1): its one caller (buildInjector) holds a rule host and no port, while the producer that authors both halves writes the allowlist entry PORT-QUALIFIED ("m.corp:443", F106) and the injection rule BARE. Reading only the port-less map made those two contradict, so buildInjector — and therefore NewServer, and therefore the sidecar of every run on an estate with a corporate artifact mirror — failed closed at boot. An entry the operator port-qualified is the same host named in writing; the port clamps that guard the CREDENTIAL live where a port exists to check (injectableTransport, mitmPortAllowed), and egress itself is unchanged (evalHost never reads this).

DENY STAYS SYMMETRIC WITH ALLOW. The two port-less deny checks below cannot see a port-qualified deny (CompilePolicy routes those to deniedExactPort alone), so widening the allow side to "any authored port" without widening the deny side would have made "allow m.corp:443 + deny m.corp:443" BUILD an injector that the port-less-on-both-sides code refused. The port-qualified arm therefore asks the only question that is meaningful once ports are in play: did the operator author a port for this host that they did not also deny? At least one surviving port means the credential has somewhere the operator sanctioned to go; none means every authored port was taken back in writing.

func (*Policy) AllowsLiteralIP added in v0.5.0

func (p *Policy) AllowsLiteralIP(host string, port int) bool

AllowsLiteralIP reports whether host (a literal IP address, already lowercased with any trailing dot trimmed) is explicitly present in this policy's EXACT allowlist for port — either a bare entry (matches any port) or a port-qualified one. Only an exact, operator-authored entry counts (never a wildcard): a literal IP the operator typed into AllowedDomains directly (e.g. an egress-redirect "To" target or corp registry that lives on RFC1918 space, per SiteConfig.EgressRedirects) carries none of the DNS-rebinding risk the unconditional private-IP guard exists to catch — there is no hostname to rebind — so evaluate() treats it as trusted instead of hard-denying it (W13-S1-3). Deny still beats allow.

func (*Policy) AuthoredPortFor added in v0.7.0

func (p *Policy) AuthoredPortFor(host string, port int) bool

AuthoredPortFor reports whether the operator authored a PORT-QUALIFIED allowlist entry covering host:port — "vendor.example:8443", or the wildcard form "*.example.com:8443".

It is the closest thing the compiled policy has to declared TRANSPORT INTENT (F110): a BARE entry matches any port, so it says nothing about which port the operator meant; a port-qualified one is the operator naming the port in writing. Credential injection over CLEARTEXT reads it that way — see Proxy.injectableTransport, which asks this only AFTER its unconditional clamp on port 443, so an authored `host:443` can never re-admit a cleartext credential to the TLS port. Deny still beats allow, on the same two lookups AllowsLiteralIP uses.

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).

func (*Policy) GitPushAnyBranch added in v0.7.0

func (p *Policy) GitPushAnyBranch() bool

GitPushAnyBranch reports whether this run's policy opts its brokered pushes out of branch-namespace confinement. Nil-safe like ToolEffectFor: a proxy built without a compiled policy keeps confinement ON.

func (*Policy) ToolEffectFor added in v0.7.0

func (p *Policy) ToolEffectFor(tool string) (types.ToolEffect, bool)

ToolEffectFor reports what a run's policy says about one tool call, and whether a rule matched at all.

Match order: the exact tool name, then the "*" default. There is no pattern matching — see ToolRule's doc for why a glob over tool names is the wrong shape here.

A run with NO rules returns ok=false, and the caller must fall back to today's behaviour (raise an approval). That fallback is what makes this field additive: a policy authored before it behaves exactly as it did.

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) Addr

func (s *Server) Addr() string

Addr returns the configured listen address.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe() error

ListenAndServe starts serving and blocks until the server stops.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops the HTTP server, stops the token renewer, and drains the decision sink.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL