Managoat.Broker

An egress credential proxy for sandboxed agents. The sandbox holds a placeholder where a credential used to be, plus a proxy address with a session token in it. Every outbound HTTP request goes through this proxy, which looks the token up in a session store the host implements, gets back the rules the host prepared for that session, and attaches the real credential to each request that matches one. The agent process never holds the credential; the proxy is the only host it may reach, so a placeholder is worthless off the box.

# The host's supervision tree:
children = [
  {Managoat.Broker,
   port: 14322,
   store: MyApp.BrokerSessions,        # a Managoat.Broker.Store
   ca_seed: MyApp.broker_ca_seed(),    # 32 bytes, the same on every replica
   allow_private_upstreams: false}
]

# The sandbox's environment, prepared by the host:
#   HTTPS_PROXY=http://<token>:<label>@broker.example.com:14322
#   GITHUB_TOKEN=__github_token__
# and the root from Managoat.Broker.ca_pem/0 in its trust store.

What the proxy does

One plaintext HTTP listener (TLS toward the sandbox, if any, is the ingress's job) speaking the two things a forward proxy speaks:

Either form may name an IPv6 literal, bracketed: CONNECT [::1]:8443 and GET http://[::1]:8080/x. Names are resolved over A and AAAA, and the vetted addresses are dialed IPv4 first — every host that worked before takes the address it took before, and IPv6 is a path for hosts that previously had none. A leaf for a literal carries an iPAddress SAN rather than a dNSName one, so it validates as a client verifying an address expects.

The client authenticates with Proxy-Authorization: Basic base64(token:label), which is what an HTTP client sends for a proxy URL with userinfo. Inside a tunnel the token is looked up once per client connection (the unit a sandbox's HTTP client pools on, and the unit a tunnel's fixed destination makes sensible); on the absolute-form path it is looked up per request, since each request carries its own and trusting the first would serve a token the proxy never checked. proxy-authorization never reaches the origin. A missing, unknown or expired token is 407.

Two guards protect the operator's network and the tenant's intent:

The Store behaviour

@callback lookup(token :: binary()) :: {:ok, Managoat.Broker.Session.t()} | :error

For lookup-only sessions, that is all the proxy needs: the raw token in, a session with its rules (credentials already resolved) out. Creating, releasing and sweeping sessions are the host's business; they touch its tables and its key hierarchy, and the proxy never needs any of it. Hashing the token before storing it is the host's choice inside lookup/1; the library passes the raw token from the header.

A store with several instances (one per listener in a test) implements lookup/2 instead and is configured as store: {Module, instance}. Managoat.Broker.Store.Memory is the reference store, an Agent holding a map, for the library's tests and for a consumer without a database.

A Managoat.Broker.Session has rules, unmatched_host_policy (:passthrough or :deny), expires_at and an opaque meta map the host fills for its own logging. A Managoat.Broker.Rule has a pattern (host[:port][/path], wildcards allowed; an IPv6 literal is bracketed, [::1] or [::1]:8443, since otherwise there is no telling which colon is the port separator), a scheme and the fields the scheme needs:

scheme fields effect on a matched request
:bearer credential Authorization: Bearer <credential> replaces any Authorization
:basic credential as {username, password} Authorization: Basic base64(username:password)
:api_key header (default Authorization), prefix, credential <header>: <prefix><credential>
:custom template (%{header => "text {{ KEY }}"}), credential (%{"KEY" => value}) each header rendered from its template
:substitute placeholder, credential every header value and the request target have the placeholder replaced by the credential
:passthrough none forwarded untouched; under deny, how a host is allowed

When several rules match, the most specific one that sets a header does: an exact host beats a *. wildcard (whatever their paths), then a pinned port beats any port, then the longest literal path prefix wins, and declaration order breaks what is left — so defaults written first with overrides appended work the way they read, and a list of equally-specific rules resolves exactly as declaration order alone would. These are Agent Vault's tiers. A :passthrough rule never displaces a rule that injects, however specific it is: it is how a host is allowed under deny, not a way to suppress injection. Every matched :substitute rule applies, in declaration order, to the header values and to the request target. A credential goes into the target byte for byte — nothing is percent-encoded on the way in, because the proxy cannot know which URI component a placeholder sits in nor what the origin expects, and the canonical case says so: a bot token is <digits>:<rest> in a path segment, where : is legal unencoded and %3A is a different URL. A credential holding a control character or a space would split the request line, so it is refused with 403 rather than written into a target.

A rule the host could not put a credential in — credential left nil, or holding something other than the shape its scheme needs — has no header to build. Every request it matches is refused with 502, carrying error: :credential_missing on the request event, rather than being sent without the header: the broker failed to obtain a credential, which is not the agent doing anything wrong, and 502 is what tells it to retry once the credential is provisioned. 403 would say it is not allowed, which is a different and misleading thing. Inside a tunnel the request is refused without ending the tunnel, unless the refused request left a body behind it in the stream or an earlier response is still being relayed. :substitute and an unfilled {{ KEY }} are the exceptions described above: a placeholder the origin can see is the clearer failure there.

A placeholder must be distinctive enough to be one: four characters or more, holding a letter or digit, and carrying a boundary — __ at either end, or a character outside [A-Za-z0-9_]. Substitution is a literal find-and-replace, so id would rewrite every id in a path and account_sid is a real field name that appears in URLs. Managoat.Broker.Injector.valid_placeholder?/1 is the check; call it when building a session, so a bad rule fails where it is written rather than on every request it would have matched.

Rules match against the target the client sent, and telemetry is derived from that same original, so a placeholder in a path is logged as the placeholder and one in a query is not logged at all.

Per-request authorization

A host can opt a session into fresh authorization by setting its authorization field to a non-secret, server-controlled reference, for example a session ID plus credential owner, grant ID and generation. nil is the default and preserves lookup-only sessions. The reference is not request telemetry and must never be taken from sandbox headers.

The configured store then implements an additional callback:

@callback authorize(authorization :: term(), request :: Managoat.Broker.Store.request()) ::
            {:ok, [Managoat.Broker.Rule.t()]} | {:error, :denied | :unavailable}

Instance stores implement authorize(instance, authorization, request). The request is %{scheme: :http | :https, host: host, port: port, method: method, target: target}. Host, port and scheme describe the actual proxy destination, independently of the sandbox's Host header; target is the original request target, before substitution, including any query. Do not log this map wholesale.

The callback runs before injection and forwarding of every HTTP request, including each request inside an existing CONNECT tunnel. It must check durable authority for the pinned reference, serialized with revocation, and resolve the current credentials from that same read. Returned rules replace the initial rules for this request only. The original reference, expiry, unmatched-host policy and telemetry metadata remain pinned; no returned credentials are cached for later requests. Initial rules still check CONNECT reachability under :deny and can contain patterns with no credential. They never provide a fallback after an authorization failure.

Denied authority (including a locally expired opted-in session) returns 403 with error: :authorization_denied. Unavailable authority, a missing callback, an exception/exit/throw or a malformed result returns 503 with error: :authorization_unavailable. On the absolute-form path both close the client connection; inside a tunnel a bodyless 403 keeps the tunnel (see "Connections, and who decides them") and the next request is authorized again, while a 503 closes it. Callback payloads and exception messages are neither logged nor returned. A missing grant or session must be denied by the host, even if its initial lookup succeeded on a different node before revocation.

The callback runs synchronously in the connection handler. Hosts must bound their database/provider waits and release any locks before returning. Admission is that host's serialized authority check: a request admitted before revocation may finish, including its streamed response. The next request must check again, regardless of missed invalidation notifications.

Set http_only: true alongside authorization when later operations must remain HTTP requests. The callback alone does not authorize operations inside an upgraded byte pipe. Protected credentials additionally require Session.protected, described below, and hosts must drain legacy connections before adopting the custody boundary.

HTTP-only sessions

Session.http_only defaults to false, preserving ordinary WebSocket and other upgrade behavior. A server-controlled true pins the session to HTTP request/response traffic, independently of which rules the request matches or what its authorization callback returns.

Hosts must set the policy when issuing sessions. Existing cached sessions and already-upgraded sockets do not acquire it retroactively: invalidate and drain them on every serving node before adopting the protected path. The library does not provide that deployment-wide drain mechanism.

Protected credentials

A host may pin one protected bearer per session, separately from ordinary rules. The policy is non-secret and server-controlled; neither account owners nor sandbox headers may construct it or widen it. There are no provider-specific defaults in the library:

%Managoat.Broker.Session{
  authorization: pinned_session_and_grant_reference,
  http_only: true,
  protected: %Managoat.Broker.ProtectedRule{
    name: "subscription",
    host: "provider.example",
    port: 443,
    paths: ["/responses", "/models/"],
    methods: ["GET", "POST"],
    identity: "account-123",
    identity_header: "x-account-id",
    allowed_headers: ["content-type", "accept"],
    # The default. `:allow` forwards a query string under the bearer.
    query: :refuse
  },
  rules: ordinary_rules
}

The store's existing authorize/2 or instance callback receives the original request with protected: true, added by the proxy, and must return {:ok, %Managoat.Broker.ProtectedCredential{bearer: fresh_token, identity: "account-123"}}. It must check the durable session/owner/grant/generation binding and obtain both values from that same authorized read. The identity must equal the policy's pinned identity. Denied authority is 403; an unavailable, malformed, wrong-shaped or mismatched-identity result is 503. The credential has redacted Inspect and no JSON encoder. It never enters an ordinary rule, template map, substitution, session cache, or telemetry record. Do not put its fields there in host code either.

The proxy enforces the policy independently of what ordinary rules say:

The bearer in a response

The sandbox never holds the bearer, so the one way left for it to get there is for a response to repeat it: an origin that echoes the request, an error page from whatever sits in front of it, a debug endpoint. Only the proxy knows the value at that moment, so only the proxy can look. Every response to a protected request is searched for the bearer that request was sent with, and refused if it holds it. This is on for every protected rule and there is no option to turn it off. Responses to every other request are not searched and take the path they always did.

It refuses rather than scrubs. Rewriting a framed stream means re-framing it (a Content-Length that is no longer true, chunk sizes to recompute), and a scrubber that misses one spelling still ships the rest of a response the origin should never have produced. A cut connection is a failure an agent already handles.

What is recognised is the bearer's exact bytes, which any longer string holding it (Bearer <value>, a JSON document quoting it) contains, plus its JSON spelling with / written \/. What is not recognised: base64, hex, percent-encoding or any other transformation of the value; a truncated or partial copy; a body that is compressed or encoded without a Content-Encoding saying so; a copy in the response to some other request or on another connection; and any secret other than this bearer (an ordinary rule's credential is not searched for). It narrows what an accidental echo can do. It is not a defence against an origin that sets out to exfiltrate.

ProtectedRule.valid_session?/1 checks persisted policy without credentials. Invalid policies, missing authorization references, or disabled HTTP-only mode are rejected at initial lookup. The rule supplies CONNECT reachability under :deny, but the HTTP request still needs fresh authorization. Protected request events use scheme: :protected_bearer; :protected_destination, :protected_query and :protected_conflict identify policy refusals, and :credential_reflected and :protected_response_encoded a refused response, all without secret payloads.

The host must still enforce write-time policy ownership, prevent reserved credential aliases in its compiler, fence issuance/updates with revocation, and drain legacy sessions on every node. Fountain's protected compiler, provider-specific routes/client fixture, durable broker fencing and drain integration are not part of this library release.

Connections, and who decides them

Absolute-form plain HTTP keeps the sandbox's connection alive, so apt and its kin get one connection rather than one per request. Each request on it is authenticated, host-checked, matched against the session's rules and given its own read deadline again — nothing is carried over, so the second request on a connection is decided exactly as the first was, and a deny session refuses a host it does not allow whether it is asked first or tenth.

The two hops are decided separately, which is what a proxy is supposed to do with a hop-by-hop header. The origin is asked to close and its connection is dropped after the response; the sandbox is told what this hop is doing, which means the response head is re-emitted with the proxy's own Connection rather than the origin's. That head is the one place a response is not relayed byte for byte — bodies still are, which is what keeps a streamed reply a stream. A connection ends after one request when the client asked for that, when it speaks HTTP/1.0, when the response ends only at the origin's close (there is no boundary to follow), or on any refusal.

CONNECT tunnels keep alive too. Rule processing, and authorization for opted-in sessions, run for each HTTP request inside the tunnel.

A refusal inside a tunnel refuses the request, not the tunnel. A 403 (a denial: deny policy, a protected route or query, denied authority, a conflict) or a 502 (:credential_missing) is answered without connection: close and the tunnel goes on to the next request, which is decided afresh — so a client refused one route of a host can reach an allowed route of it without a new TCP and TLS connection. The tunnel still closes when the refused request carried a body (the proxy does not read a body it is refusing, so the next request could not be found in the stream), when an earlier response on the tunnel is still being relayed (the proxy's own reply would otherwise land inside it), on a 503, and on the HTTP-only refusals of a request's own shape (:protocol_upgrade, :unsafe_request). Absolute-form plain HTTP still closes on any refusal, as above.

The child spec

{Managoat.Broker, port: 14322, store: Module, ca_seed: <32 bytes>, allow_private_upstreams: false}. Every option but the last is required, and a missing one raises at start naming the option. Optional: upstream_ssl_options (merged over the :ssl options the proxy dials origins with; a test origin's cacerts), max_cached_leaves (default 1024; see The CA) and name (default Managoat.Broker, for several listeners in one VM). There is no configuration module reading an otp_app: the listener is started by the host with values the host computed at boot, and a library that is not started serves nothing.

Once up: Managoat.Broker.port/1, running?/1 and ca_pem/1.

The CA

A brokered sandbox trusts one root, and the proxy presents a leaf for each host the sandbox CONNECTs to, signed by that root. Every replica of the host must present leaves the sandbox trusts, whichever one the ingress hands a connection to, and a sandbox that survived a restart must still trust what a fresh replica signs. So the root is not generated and stored: it is derived from the 32-byte ca_seed with HKDF-SHA256, reduced into P-256's scalar field, and the certificate's subject, serial and validity are fixed, so every replica computes the same key, subject and serial from the same seed. Rotating the seed rotates the CA; nothing else does.

The root's self-signature bytes vary per derivation, because ECDSA signing is randomised, so two replicas' PEMs differ byte for byte. That is harmless: a client matches a trust anchor by subject and public key and never verifies a root's own signature. ca_test.exs proves a leaf signed after a re-derivation chains to the first root.

The seed is the host's to derive, and it must not be a key the host uses for anything else: derive it from a master key with a fixed info string, so the CA key is never the storage key. Leaves live thirty days, are cached per host in an ETS table the listener owns, and are re-signed after twenty-nine.

That cache is bounded, because its key is the host from a sandbox's own CONNECT line: under :passthrough an agent browses wherever it likes, and a wildcard DNS record aimed at one address makes every *.attacker.example a distinct name that resolves, connects and would otherwise be cached for the life of the listener. It holds at most max_cached_leaves of them — 1024 by default — and the least recently used goes first, so a host still being visited keeps its leaf and a busy listener does not become a re-signing treadmill. Going over the cap costs one ECDSA signature the next time a fallen-off host is seen; it refuses nothing, which is why it is an option with a default rather than a decision the host has to make.

A host is validated before any of that. The name off the request line reaches :inet.getaddrs, the TLS server_name_indication, that cache's key and the subject and SAN of a certificate this proxy signs, so a host longer than 253 bytes, or holding a control character, whitespace or any of @ / \ ? # %, or beginning or ending with a dot, is 400 before it is resolved, dialed, cached or signed. It is not sanitised: forwarding a name the client did not ask for is worse than refusing the one it did. This is Agent Vault's brokercore.IsValidHost less its DNS-name blocklist (localhost, metadata.google.internal and two more), which is not worth porting — the SSRF guard above works on the addresses a name resolves to, so it catches every name that reaches a blocked range rather than the four anyone thought to write down.

Telemetry

Every request the proxy decides about emits [:managoat, :broker, :request] with the measurements %{count: 1, duration: <native units>} and the metadata method, host, path, outcome (:injected, :passthrough or :denied), rule (the applied rule's name, or nil), scheme (that rule's scheme, or nil), status, error and meta (the session's, unchanged). Never a header, never a body. The host attaches a handler and writes its log line with whatever meta carries; the library logs only refusals, which have no session to attribute.

outcome answers "did a rule apply", not "was a credential attached", and the two are not the same question. :passthrough means no rule matched and the session's policy let the request through, so it never appears under unmatched_host_policy: :deny. A matched :passthrough rule — the documented way a host is allowed under deny — is outcome: :injected with that rule's name, because a rule did apply; nothing was attached to the request.

scheme is what separates them, and it is the field an audit log should read. In a default-deny session the two rules a consumer most wants to tell apart are exactly the two that outcome collapses:

rule outcome rule scheme
api.stripe.com, :bearer :injected "stripe" :bearer
registry.npmjs.org, :passthrough :injected "npm" :passthrough
no rule matched, policy :passthrough :passthrough nil nil
refused :denied nil nil

The event is terminal: one per request, emitted when the request is over rather than when it starts.

A consequence worth planning for: a long-lived request is not recorded until it ends, so a streaming reply appears in a host's audit log when the stream finishes. That matches Agent Vault's total-duration semantics and avoids a second event and a row-update protocol. If immediate visibility for long-lived requests is ever needed, that is correlated start/stop events, not more meaning packed into this one.

For ordinary sessions, framing only observes the relay: every byte is written to the sandbox before the framer sees it. HTTP-only sessions add a response-head gate before the write, so malformed framing can close the connection. Accepted body bytes still stream in both modes.

path is the URL path and nothing else. A query string never appears in it, on either request path, because a query can already hold a credential this proxy never brokered — a signed URL is one in itself, and ?key= is a shape clients use. The origin receives the request target unchanged; only the event is narrowed. This is Agent Vault's contract too: its request log recorded r.URL.Path. A CONNECT names an authority rather than a path, and is reported as it was sent.

Deviations from Agent Vault

This proxy replaced Infisical's Agent Vault behind the same interface. The parity suite (test/managoat/broker/agent_vault_parity_test.exs) replays the upstream tests it stands in for and lists what was not ported.

Each of these is a decision rather than a backlog item. Agent Vault is deleted from the cluster and from Fountain's codebase, so the A/B that settled the last round — the same request against both proxies, compared on the wire — no longer exists. A row reopened here has to be argued from the upstream tests in the parity suite, from Agent Vault v0.39.1's source, or from the protocol; it cannot be measured.

Deliberate, and expected to stay that way

Deliberate for now, with a condition attached

Two operational traps a host must handle

Both were found in production with the previous broker and are about the sandbox and the ingress, not the proxy, so this library cannot fix them.

Licence

Apache-2.0. See LICENSE.