The compose topology
One compose file wires four networks (image/compose.yaml; the golden image
boots the equivalent compose.tower.yaml):
The agent runtime sits on
agentnet alone. It cannot reach the internet, the
database, or vaultd directly — only the gateway. Isolation is selective: the
gateway is reachable, everything else is not.
The load-bearing test
ci/topology.test.ts boots the full stack and asserts the four properties that
make a Tower a Tower. It is never skipped.
1
Direct runtime egress FAILS
curl https://api.stripe.com from inside agent-runtime cannot even establish
a connection — no route, no DNS, no HTTP status ever returned. (Sanity check in
the same test: the gateway is reachable, proving isolation is selective, not
total.)2
The gateway terminating path WORKS — two ways, both audited
The same class of call, sent through the gateway’s terminating path, succeeds:
- a sandbox operator gets a synthetic body — no real egress, no injection;
- a production operator’s call is forwarded over the gateway’s own TLS connection with the vaultd-injected credential (the echo provider reflects the injected header back).
activity.logged audit row stamped with the operator’s
environment.3
A non-allowlisted CONNECT is REFUSED and audited
A CONNECT to
blocked.example.com is answered 403 (no tunnel opens). The
refusal is audited by host + verdict only — never tunnel contents.4
Governor stopped ⇒ 403 enforcement_unavailable
With the governor stopped, a gated action through the gateway returns
403 enforcement_unavailable. The open plumbing + gateway are inert without
the closed engine — fail closed, no cached verdicts.vaultd — the TPM-rooted secrets daemon
vaultd (Go, closed) holds the vault root key and derives everything else. It runs withnetwork_mode: none and speaks JSON over the UNIX socket
/run/vault/vaultd.sock (owner-only, 0600); only api and the gateway, which
bind-mount the socket, can reach it.
Key hierarchy
- Root key — TPM2-sealed against PCR 7 (Secure Boot state). A tamper or
an absent TPM means vaultd cannot unseal and stays inert (fail closed). Dev/CI
use
--dev-root-file(a 32-byte hex key) or swtpm. On the golden image the root file currently lives on the LUKS/TPM-unlocked/data, so the TPM protects it transitively; direct vaultd TPM sealing (and PCR 11) is a documented beta. - Per-tenant KEK =
HKDF-SHA256(root, salt="naive-vault-kek", info="company_id|tenant_user_id"). The root and the KEKs never leave vaultd. - Per-entry DEK — a fresh data key, wrapped by the tenant KEK.
- Envelope — every wrapped value is
nonce(12) || tag(16) || ciphertext(AES-256-GCM), exactly the cloud KMS layout, sovault_entriesrows port unchanged.
generateDataKey, decryptDataKey, reEncrypt, use, and
health. use() is how the gateway injects a credential: vaultd decrypts under
(ctx, ref), applies a registered injection function, and returns the injected
result (e.g. a header directive) — never the secret.
The egress gateway — three paths
The egress-gateway (closed) is the only egress-capable process on a Tower. Every request first resolves a per-operatorNAIVE_RUNTIME_TOKEN to
{ company, tenant_user, agent_profile, environment } before asking the
governor. All governor calls go through the fail-closed governorCall() wrapper.
1 · Region API passthrough
/v1/* → the region api. The governed surface. No injection.2 · Terminating provider endpoints
/proxy/<name>/*. The gateway terminates TLS, asks the governor, and then:
sandbox ⇒ synthetic body; production ⇒ fetch the credential via vaultd use(),
set the header, open its own TLS to the provider.3 · Generic web egress (CONNECT)
An HTTP
CONNECT tunnel. The governor decides the host/SNI allowlist per
operator + environment. Allow or refuse only.Limits (what the gateway deliberately does NOT do)
- No interception of a CONNECT tunnel. Path 3 is an opaque TCP relay: allow
or refuse. No credential is set inside it and no synthetic
200is faked in it. - The MITM-CA is explicitly REJECTED. The gateway does not install a trusted CA to man-in-the-middle TLS. If a destination needs a credential it must be a named terminating forward (path 2), where the gateway is the legitimate endpoint; otherwise egress is allow/deny only.
- No enforcement without the governor. A governor error or timeout is
deny-all (
enforcement_unavailable), never fail-open.
gVisor and the runc fallback
The agent runtime is sandboxed with gVisor (runsc) on the golden image.
runsc interposes a userspace kernel and therefore needs a real Linux
kernel underneath.
macOS Docker Desktop has no such kernel available to runsc, so the compose
dev stack on macOS uses runc (recorded in PREFLIGHT.md). This changes only
the in-sandbox isolation layer — the routing assertions above hold
identically under either runtime, because isolation on a Tower is enforced by
network topology, not by the sandbox runtime. runsc is exercised where it can
be: in the golden image and in Linux CI (10-docker.sh installs runsc
with the containerd SystemdCgroup=true fix; compose.tower.yaml runs the real
runsc agent-runtime).
This is why the compose quickstart works on a Mac while the production isolation
guarantee is validated on Linux: the topology test proves the load-bearing
routing on either host, and the gVisor sandbox is proven on the image itself.
See Tower golden image.