Help us shape the future of Go! Take the 2026 Go Developer Survey

egress

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package egress is the egress-time credential-injection subsystem: the shared substitution engine that rewrites vault placeholders into their secret values on outbound requests, and the host matcher both it and the per-session gate use to decide which hosts a request — or a credential — may reach. Beside the matcher sits the one host list that is neither an operator's nor a credential's: the curated package-registry set allow_package_managers opens (registries.go), which lives here because the gate and the control plane both have to be told it. So does the MCP admission predicate (mcphost.go), which session create and the executor's dial both ask and must never disagree on. It holds no I/O, and that is the invariant to preserve: internal/gate drives it against real HTTP requests — constructing the substitution engine (gate.go) and calling Substitute for header and body locations — while internal/gate/policy.go reuses NewHostSet and CanonicalLookup for the environment's allowed-host policy, and internal/vaultresolve supplies the credentials read from the store. CanonicalHost is also what internal/vaultresolve and internal/mcp compare their own hosts with, so all three answer "are these one host?" with one function.

Index

Constants

View Source
const PlaceholderPrefix = "vltph_"

PlaceholderPrefix marks the opaque tokens the platform injects into a sandbox in place of a vault secret. The reference calls the sandbox-visible value an "opaque placeholder" and defines no format; this prefix and the derived suffix are ours (recorded as a deliberate divergence). The token is a valid environment-variable value — no spaces or shell metacharacters — so it injects cleanly through sandbox.Spec.Env.

Variables

This section is empty.

Functions

func CanonicalEntry added in v0.4.0

func CanonicalEntry(h string) (string, error)

CanonicalEntry validates one allowed_hosts entry and returns the form to store. An entry written as a Unicode name is stored as its A-label, so a later comparison needs no conversion; an ASCII entry is returned exactly as written, case included, because rewriting those would change what every existing environment reads back for no gain. The store therefore holds one *alphabet* per host, not one spelling — "API.example" and "api.example" are both kept as typed, and it is the matcher that folds them into one host.

The wildcard prefix is cut before conversion and restored after, because an asterisk is a rune IDNA refuses — which is also why CanonicalHost cannot be handed a wildcard.

The length cap CanonicalHost keeps applies to the conversion, and that is exactly where it is needed: without it an operator could store an entry converted from a U-label the matcher would never convert, so the entry could not match the spelling it was written as.

An ASCII entry over the cap needs no arm here, since nothing converts it on either side — but do not read that as the two sides meeting. Match refuses a host over the cap outright, so such an entry matches nothing spelled the way it is written: measured, a 1030-byte ASCII entry is stored as typed and its own spelling is refused. It is stored rather than refused because refusing would add a rejection this API has never had, and would still not be the rule the wording invites — a hostname's limit is 253 bytes, not this one.

It is not a DNS length check, and nothing here is. An entry can still name a label over 63 bytes or a name over 253: measured, 58 "ä" runes convert to a 64-byte A-label, and ValidateHostEntry has never bounded an ASCII label either. Such an entry is stored and matches nothing, which is the pre-existing behaviour rather than something this front end introduced.

A failure names the entry as the operator wrote it, never the A-label made of it: a 400 quoting punycode nobody typed cannot be found in the config it came from.

func CanonicalHost added in v0.4.0

func CanonicalHost(h string) string

CanonicalHost returns the form of a host that two spellings of one name agree on. It is the comparison this package, internal/vaultresolve and internal/mcp share, and the reason it is not a case fold is that case folding is not what makes two hostnames one name — IDNA is.

Folding by Unicode is unsafe: strings.EqualFold merges the two Greek sigmas, which Go's non-transitional lookup punycodes to "xn--4xa" and "xn--3xa", two names that can belong to two people. Folding by ASCII is safe but withholds far more than #611 claimed: it separates every orbit EqualFold merged that IDNA also collapses, so "Ä.example" and "ä.example" are called two hosts when they are one ("xn--4ca.example" either way), and so are "Д"/"д", U+212A and "k", U+017F and "s". Comparing A-labels gets both directions right. The sweep behind that: all 1,112,064 code points through idna.Lookup.ToASCII produced 140,873 distinct A-labels, none of which moved or collided when fed back. Read it for what it is — one code point per label, fed back once. It shows no two code points merge and that the conversion is stable on its own output, which is what the orbits above needed answering; it is not a proof over arbitrary multi-code-point names, and idna.Lookup checks neither DNS lengths nor whether anyone could register the result.

What it must not do is rewrite strings that are not names. The Lookup profile validates, and it refuses shapes this platform accepts today: ":" and "[" (an authority carrying its port, a bracketed or bare IPv6 literal), "_" (an internal-DNS spelling), and — the surprise — a plain-ASCII label with hyphens in positions 3 and 4, so "s3--us-west-2.example.com" errors while ValidateHostEntry accepts it. Every one of those comes back folded, and none needs an arm of its own: each is ASCII, so the branch below returns before idna runs, and each would reach the same string by the error path if that branch were deleted. A guard for them would have been dead code, which mutation testing is how we found out. An IPv4 literal is the same story from the other side — idna returns it unchanged, measured. That equivalence is not general, though; the branch's own paragraph names the family where it fails.

The error path is the sharp edge. ToASCII returns a value *and* an error, and the value can be a different real hostname: "xn--pypi-.org" comes back as ("pypi.org", err), so an implementation that canonicalizes and drops the error makes an entry admit a domain nobody typed. The returned string is therefore discarded whenever the error is non-nil, on the entry side and the lookup side alike, so the two can never disagree about which alphabet they are in.

The ASCII branch was written as a cost guard — idna costs 128ns against the fold's 43ns, on a path the gate runs for every request — and it turns out to carry a correctness rule as well, which is worth stating because the cost reading alone invites deleting it. Nearly every all-ASCII host either passes idna unchanged or is refused by it. One family does neither: a label of "xn--" whose punycode payload is empty is REWRITTEN, and no error is returned. Measured, ToASCII answers "xn--.example" with (".example", nil) and "a.xn--.z" with ("a..z", nil). What the branch protects is the destination: the gate dials this function's answer, so without it a CONNECT to "xn--.example" would leave for ".example" — a name the sandbox never wrote. Be exact about the direction, because the guard reads like a matcher rule and is not one. Relative to converting, keeping the raw form is *broader*: the converted string carries an empty label and Match would refuse it, while the raw one reaches the suffix test, so "xn--.example.com" does match "*.example.com" here. That is the compatibility net/http keeps too — its idnaASCII has the same branch, though its one does not case-fold, so copying the function rather than the shape would undo the fold #611 landed. Plan 43 argues the whole design.

A scoped address keeps its zone identifier byte for byte. The zone names a local interface, it is case-sensitive, and folding it would call two interfaces one — so this splits at the first "%" and returns the remainder untouched. CanonicalLookup delegates here and inherits that, which is why the zone stays out of the fold on the admission side too, and it is what lets the gate dial the canonical name of a scoped address without changing which interface it means.

It folds but does not trim or de-root, so each caller keeps the normalization its own callers need: this package wraps it as CanonicalLookup, while internal/mcp's sameHost deliberately does not, because a trailing dot there is a difference that drops the token rather than sends it.

func CanonicalLookup added in v0.4.0

func CanonicalLookup(h string) string

CanonicalLookup is the form this package's matcher, and the gate's MCP endpoint map beside it, compare two hosts in: surrounding space trimmed, the host canonicalized, and then a single trailing FQDN dot stripped so "Example.com." and "example.com" are one host. It never rejects anything.

The order is the whole point, and it is not the order this started with. IDNA maps U+3002, U+FF0E and U+FF61 onto ".", so a host written "example.com。" is rooted — but only once it has been converted. De-rooting first, as a fold-then -trim helper did, could not see that dot and the empty label it leaves behind was then refused: measured, all three spellings stopped matching "example.com" and an entry written that way was dropped from the set outright. Stripping again afterwards is not the fix either — "example.com.." would lose both dots and match. One strip, after.

It is exported because the gate's MCP endpoint set has to answer a host exactly the way this package's HostSet does (internal/gate, policy.go) while staying an exact-match map. Two names for one comparison is how those two drift apart.

It trims and de-roots where CanonicalHost does neither, and like that function it leaves a scoped address's zone identifier byte for byte, because the zone names a local interface and is case-sensitive. Folding it — which the fold-then-trim helper this replaced did — called two interfaces one, and #609 recorded that as unaddressed; it is addressed now, on the admission side as well as at the dial. internal/vaultresolve's lowerHost and internal/mcp's sameHost do not use this function at all: they keep their own normalization around CanonicalHost, because a trailing dot there is a difference that withholds a token rather than sends it.

func MCPServerAdmitted added in v0.5.0

func MCPServerAdmitted(cfg domain.EnvironmentConfig, host string) bool

MCPServerAdmitted reports whether the environment's networking policy admits an MCP server the agent declares at host. Two places ask, and must never disagree: session create, where the reference asks — a refusal there is its recorded 400 (#571) — and the executor before every dial, since mcp_servers is mid-session-mutable and a host can arrive after create.

What decides is the policy's type, not which kind of environment carries it — and type rather than presence, because a value struct cannot tell an absent networking block from one that names no type: both decode to an empty type, and both read here as no policy. A self_hosted environment has no networking block by construction — its config normalizes to exactly {"type":"self_hosted"}, the REST surface rejecting every other field, and the reference documents networking on cloud environments only — so with no block there is nothing to apply and the server is admitted, as the reference admits it at create. A cloud environment always has one through the API. Either way a block that *is* present is a policy somebody meant, and one naming no recognized type is malformed rather than permissive: it admits nothing, which is `gate.newPolicy`'s shape and the safe direction.

Reading a missing discriminator as "unrestricted" would make the malformed row the one that reaches everything: the schema constrains `config->>'type'` against the kind and nothing constrains `config->'networking'`, and a config-preserving update (packages, description) carries a stored value forward without revalidating it. Deciding on the kind first would do the same thing one arm at a time — a `self_hosted` row is exactly the one the API cannot have written a block onto, so a block found there came from an import, a restore or a hand-written UPDATE, and skipping it because of the kind would leave the refusal reachable on cloud rows alone.

allow_mcp_servers "allows access to MCP server endpoints configured on the agent" on top of allowed_hosts, and this is only ever asked about a server the agent declared — both callers walk that array and nothing else — so under the flag the host is admitted without a second list to check it against.

func PackageRegistryHosts added in v0.4.0

func PackageRegistryHosts() []string

PackageRegistryHosts returns that set, as a fresh slice so no caller can grow the shared one by appending to it.

It lives in this package rather than in internal/gate because two callers ask the same question and must never disagree: the gate admits these hosts, and the control plane's credential-conflict detection has to be told exactly what the gate is told or it reports a credential reachable on one of them as permanently unreachable (internal/api/gateconfig.go).

func Placeholder

func Placeholder(sessionID, secretName string) string

Placeholder derives the opaque token the sandbox sees in place of the vault secret named secretName for the given session: the prefix plus 128 bits from SHA-256 of (sessionID, secretName), in hex.

It is deterministic on purpose. The sandbox binds its environment at container create and keeps it across the idempotent re-provisions of a session (sandbox.Spec.Env is "fixed at create"), so every executor pass — and later the egress gate resolving live secret values — must derive the exact token already in the sandbox, not mint a fresh one that would no longer match. Stability is per (session, secret_name): a rotated credential (same secret_name, new secret) keeps its placeholder and the gate resolves the new value under it. The token is opaque and not itself a secret — the per-session gate only substitutes a session's own placeholders, and only for a host the credential's allowed_hosts admit, so correctness needs a stable derivation, not an unguessable one. The NUL separator keeps ("a","bc") from colliding with ("ab","c").

func ValidateHostEntry

func ValidateHostEntry(h string) error

ValidateHostEntry checks one allowed-hosts entry against the grammar this package matches: a bare hostname, an IPv4 literal, or a "*."-prefixed wildcard on a hostname. It is the single source for the grammar — every allowed_hosts writer reaches it through CanonicalEntry, and the executor's WEBTOOL_ALLOWED_DOMAINS fails startup on the first bad entry, because an out-of-grammar entry matches exactly the string it was written as and nothing else, and no request host can be that string: the gate hands the matcher a host with the port already split off, and the bracketed authority that survives that split carrying a colon is refused by http.ReadRequest first. So a typo reads as the operator's fence when it is really a hole in it.

Types

type Credential

type Credential struct {
	Placeholder  string
	Secret       string
	Hosts        *HostSet
	Unrestricted bool
	Header       bool
	Body         bool
}

Credential is one resolved environment-variable vault credential: its sandbox-visible Placeholder, the Secret it stands for, the hosts it may be used against, and which injection locations it is enabled for. Secrets live here only for the substitution call path — never logged, never stored.

Unrestricted is the credential's own networking arm: when set, the secret may be substituted for any request host and Hosts is ignored (the credential was created with networking "unrestricted"). Otherwise Hosts is the limited allow-list — a nil/empty set matches nothing (fail-closed).

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine holds the resolved credentials for one substitution pass, keyed by placeholder. internal/vaultresolve reads them from the store and the control plane hands them to the gate (api/gateconfig.go), which builds an engine per session and drives Substitute over each outbound request.

func NewEngine

func NewEngine(creds []Credential) *Engine

NewEngine builds an engine over a set of resolved credentials. The slice is small (a session's attached env-var credentials), so Substitute scans it directly rather than pre-indexing.

func (*Engine) Substitute

func (e *Engine) Substitute(host string, loc Location, s string) (out string, unreachable []*Credential)

Substitute rewrites s for a request bound to host, in injection location loc. A credential enabled for loc whose placeholder appears in s is substituted with its secret when host is admitted by the credential's allowed_hosts; when it is not, the placeholder is left literal (the opaque token, never the secret, reaches the third party — the reference's documented behavior, not an error) and the credential is returned in unreachable as a diagnostic; the wire-visible credential_host_unreachable_error is a config conflict the controlplane emits at gate-config render, never a per-request report. A placeholder whose credential is not enabled for loc is left literal and is not unreachable — the documented "a disabled injection_location is neither substituted nor stripped". Each unreachable credential is reported once.

This path is uninstrumented: no span, no metric, no decision log. Plan 12 intended a per-substitution span and it was never built, so an operator asking "why was my credential not substituted?" has only the gate's egress_request span, which carries the method and server address and no verdict. Said here rather than in that plan alone, because the absence is the answer to a question this function invites.

type HostSet

type HostSet struct {
	// contains filtered or unexported fields
}

HostSet matches a request host against an allowed_hosts list in the grammar the vault API validates (internal/api/vaultcredauth.go): a bare hostname, an IPv4 literal, or a "*."-prefixed wildcard. It is the one matcher shared by a credential's allowed_hosts (may this secret be used for this host?) and an environment's networking allow-list (may this request leave at all?).

A wildcard "*.example.com" matches any subdomain but never the apex (example.com) — the reference's recorded behavior ("a `*.`-prefixed entry matches any subdomain of the named domain but not the domain itself"; checked against anthropic-sdk-go v1.70.1 — betavaultcredential.go BetaManagedAgentsLimitedCredentialNetworkingResponse.AllowedHosts). "Any subdomain" is read as any label depth (a.example.com, a.b.example.com), the one residual the SDK wording does not pin (recorded in DIVERGENCES).

func NewHostSet

func NewHostSet(entries []string) *HostSet

NewHostSet builds a matcher from allowed_hosts entries. Entries are assumed to have passed CanonicalEntry below. One that did not is not rejected here — it is keyed as it canonicalizes, and what that costs depends on the entry. Every one of them matches exactly the string it was written as and nothing else, so the question is whether a request host can ever be that string. "https://x.com" and "x.com:443" cannot: the gate splits the port off before it asks (hostOnly on a CONNECT authority, url.Hostname on a plain-HTTP one), and the one shape that survives that split carrying a colon — a bracketed authority like "[x.com:443]:443" — is refused by http.ReadRequest before any handler runs. "::1" and "_acme.example.com" can be a request host, and match. A nil or empty list matches nothing.

func (*HostSet) CoversEntry

func (s *HostSet) CoversEntry(entry string) bool

CoversEntry reports whether every host an allowed_hosts entry names is admitted by the set — the config-conflict probe behind the reference's credential_host_unreachable_error ("a credential's allowed_hosts includes a host the environment's network policy does not permit"). An exact entry is covered iff the set matches it. A wildcard entry names a whole subdomain family, which only a wildcard of the set can cover: "*.D" is covered iff the set has a suffix S with D == S or D under S — a set's exact host can never cover a family, and a broader wildcard is not covered by a narrower one. A malformed entry covers nothing and is reported uncovered (fail-closed: it names something the policy cannot admit).

func (*HostSet) Match

func (s *HostSet) Match(host string) bool

Match reports whether host is admitted by the set. Matching is case-insensitive and tolerant of a trailing FQDN dot. A malformed host — empty, or carrying an empty label (a leading dot or a ".." run) — never matches: the API validates entries against the same grammar, so admitting an out-of-grammar request host would let ".example.com" slip past the "*.example.com" boundary. A nil set (a credential resolved without a host list) matches nothing.

type Location

type Location int

Location is where in an outbound request a placeholder was found — the two injection_location arms a credential can enable.

const (
	LocationHeader Location = iota
	LocationBody
)

Jump to

Keyboard shortcuts

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