Skip to content

The egress witness ​

An HTTP forward proxy for a locked-down agent's connections that do not go to the gateway. It relays CONNECT and plain HTTP, with no TLS interception. Each connection is tied to an agent by the proxy credential the witness checked, decided by a Cedar or Rego policy per destination, and receipted either way with issuer.kind: "egress". The guide is the egress witness, and credential custody and lockdown covers the deployment; this page is the exact contract.

It sees the destination, the times, and the byte counts. It never sees payloads. It sees nothing that skips it, which happens whenever lockdown is off, and it relays no protocol other than HTTP. Each receipt is an observed bypass, not a record of what was done.

Config ​

egress.json, paths relative to the file. loadEgressConfig(path); schema EgressConfigSchema.

keytypedefaultmeaning
identity.keyFilepathrequiredthe witness's signing key
agents{ id, secretEnv }[]required, at least oneeach agent and the environment variable holding its proxy secret; ids are unique, with no colon or whitespace. Only the witness and that agent hold a secret, never the gateway, which could otherwise connect through the witness as that agent. In the docker compose lockdown each agent's secret is in lockdown/agents/<agent>.env, and the gateway image runs no command but egress with an EGRESS_SECRET_* variable in its environment (credential custody)
policyFilepathrequiredCedar, or Rego when the name ends in .rego (compiled by opa build, which must be on PATH when it loads); re-read when it changes, and a file that does not parse or compile leaves the policy in force. A changed Rego file is compiled on the first connection after the change, synchronously: the whole witness waits for opa build, typically a few hundred milliseconds, so every other connection waits too. The compile has no deadline, so an opa that hangs stalls the witness until it exits or the process is killed. A file that does not compile is compiled once, not again until it changes
modeenforce | observeenforceobserve relays every connection from a verified agent and records enforced: false; it also relays when the log refuses the commit, and that receipt fails egress open committed before relaying
connectTimeoutMsinteger10000wait for the destination to accept
idleTimeoutMsinteger300000close a relayed connection idle this long
maxInFlightinteger256connections at once; past it, 503 with no receipt
receiptsDirpathrequired
logFile or logexactly oneas for the gateway
otel, splunkoptionalas for the gateway

Running it ​

bash
agent-custody egress --config egress.json [--port 8791] [--host 127.0.0.1]

In code:

ts
import { createEgressHost, serveEgress, loadEgressConfig } from "@agent-custody/receipts";
const host = await createEgressHost(loadEgressConfig("egress.json"), { autoFlush: true });
const running = await serveEgress(host, { port: 8791, host: "0.0.0.0" });
// on shutdown: host.close() receipts each open connection as witness-stopped, then running.close()

createEgressHost(cfg, { log?, exporter?, autoFlush?, lookup? }) returns { keyid, policyDigest(), handleRequest(req, res), handleConnect(req, socket, head), flushOutbox(), pendingReceipts(), close() }. draftEgressPolicy(receiptsDir, { issuerKeys, logKeys?, logFile? }) returns { policy, agents, read, used, skipped }: the draft text that agent-custody egress draft-policy prints, each agent with its destinations (name, scheme, count, first, last, internal), and each skipped file with its kind (not-a-file, unverified, not-egress, no-agent, not-relayed, unsafe-name) and reason. DRAFT_WARNING holds the lines the draft starts with. The guide is writing egress policies.

Also exported: checkProxyCredential(header, agents), parseAuthority(authority), normalizeHost(host), destinationName(host, port), DESTINATION_FACT, EGRESS_VERSION, PROXY_REALM.

Requests ​

  • CONNECT host:port: a tunnel. The port is required. A host is a DNS name or an IP address, and an IPv6 address is in brackets. The host is made canonical by the URL parser, as a plain request's is, so 2130706433:443 is decided on as 127.0.0.1:443. A name whose last label is all digits or starts with 0x, and an IPv4 part with a leading zero in the verifiers, is never treated as a name for an address.
  • A plain request in absolute form, GET http://host[:port]/path. It is relayed without hop-by-hop headers, Proxy-Authorization, or Expect, and with Host set to the target. An https:// URL, or credentials in the URL, gets 400.
  • A request in origin form is for the witness itself. GET /.well-known/agent-custody-egress answers { keyid, policyDigest, version, pendingReceipts }. Any other path gets 404.
  • Proxy-Authorization: Basic base64(id:secret) identifies the agent. Nothing else does.

The Cedar request ​

  • Principal: Agent::"<id>" if the credential verified, otherwise Agent::"unknown".
  • Action and resource: "host:port".
  • Context: args: { method, host, port }, and facts.destination: { host, port, scheme, credential, declared?, remote }.

Without a verified credential, the connection is refused with 407 in every mode.

The Rego input ​

For a .rego policy file, the same request with plain strings, under package agentcustody with allow (a boolean) and deny (a set of strings), as in the gateway's Rego contract:

Regovalue
input.principalthe agent id if the credential verified, otherwise "unknown"
input.action, input.resource"host:port"
input.context.args{ method, host, port }. Provenance: claimed.
input.context.facts.destination{ host, port, scheme, credential, declared?, remote }. Provenance: observed.

deploy/gateway/lockdown/config/egress-policy.cedar in Rego:

rego
package agentcustody

# The research agent may read the package registry and the public docs over TLS.
allow if {
	input.principal == "research-agent"
	input.action in {"registry.npmjs.org:443", "docs.python.org:443"}
}

# Any known agent may reach hosts under example.com on 443; context.facts.destination is the witness's own observation.
allow if {
	d := input.context.facts.destination
	d.credential == "verified"
	d.port == 443
	endswith(d.host, ".example.com")
}

The receipt's policyDigest is the sha256 of the file's text, as for Cedar. An OPA server (opa: { url }) is not supported: the witness decides each connection synchronously before it relays, and a query to a server would put a network round trip outside the witness in front of every connection.

Responses ​

statuswhenreceipt
200CONNECT allowed (Connection Established, then the tunnel)executed at close
the destination'splain HTTP allowedexecuted at the end
400malformed targetnone
403the policy refuseddenied
407no valid credential (Proxy-Authenticate: Basic realm="agent-custody egress")denied
500the receipt of a refusal could not be written; nothing was relayednone
502the destination could not be reached, or a host name resolved only to internal addresseserror
503the log would not commit the connection firstwithheld
503the witness stopped before the connection openederror
503past maxInFlight, or the witness is stopping when the request arrivesnone
504plain HTTP: the request may have reached the destination and no answer cameerror

Answers with a receipt carry x-agent-custody-receipt. If the log did not take the receipt yet, the answer also carries x-agent-custody-log-pending: true.

The receipt ​

  • issuer: { kind: "egress", keyid, version, framework: "connect" | "http" }

  • principal: { id: null, provenance: "claimed" }

  • agent: { id, provenance: "observed" } when verified, otherwise { id: "unknown", provenance: "claimed" }

  • tool: { name: "host:port", provenance: "observed" }

  • request.args: { method, host, port }, claimed

  • facts.destination, observed

  • execution:

    • executed, with result: { peer, openedAt, closedAt, bytesUp, bytesDown, end, status? }; end is closed, reset, idle-timeout, or witness-stopped
    • denied, with reason
    • error, with error
  • authorization: on every relayed connection, the statement committed to the log before any byte moved

The verifier checks are egress receipt carries a policy decision, egress destination observed, egress relayed only for a checked agent, egress open committed before relaying, and egress peer matches the destination.

A host name is never dialled at an internal address: the resolver drops loopback, private, shared, link-local, metadata, multicast, and reserved addresses, and IPv4-mapped or NAT64 addresses that carry one, before the connection is made. An IP literal is dialled only when the policy names it. publicOnlyLookup(resolve?), internalAddress(ip), sameAddress(a, b), and isAddressLiteral(host) are exported.

Not yet ​

  • The byte counts of a connection open when the witness is killed: the committed authorization is in the log, the closing receipt is not.
  • Agent identity by source address or by signed grant.
  • agent-custody check for an egress config.
  • A decision from an OPA server. A .rego file works.