Connector configuration¶
The flat configuration hub remains the compatibility route. This page is the canonical source-checked field reference for its family, including nested tables and list rows.
Paths in the tables are relative to [connector], [[connector.backends]],
or the nested heading. Each row gives the serde type and source default or
unset meaning; credentials and host-key material belong in protected files.
Check parsing, unknown keys, and backend cross-field rules with
gdsgate doctor --config <PATH> --json, then follow the connector
deployment guide.
[connector]¶
Used by: Connector.
What a connector serves. The fields outside [[connector.backends]] are
connector-wide; the backends list is per-resource.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
id |
string | "local" |
Connector | Identifier this connector registers under at the Proxy. Two connectors with the same id race for the same tunnel slot, so pick distinct ids per connector. |
backends |
array of tables | [] |
Connector | The resources this connector serves. Empty, the connector registers nothing. |
authority_transport |
enum | "direct" |
Connector | Route the connector's Authority control-plane calls take. See below. |
authority_transport picks which address the connector's Authority calls go to. It
changes the connector's egress only: Authority authenticates the connector by its
own certificate in every mode, and the data plane, the reverse tunnel to the
Proxy, is untouched either way.
Set on Authority's file it says something else: which of the three routes this
cluster runs its fleet on. Authority advertises that to every
node it issues an identity to. Authority binds nothing from the key and does not
read it for itself, as it does not read [client] for itself.
| Value | Egress | Authority address |
|---|---|---|
direct |
two connections, one to the Proxy and one to Authority | endpoints.authority, or its default when unset |
fronted |
one address, shared with the Proxy | endpoints.authority when set, otherwise endpoints.proxy_internal verbatim |
tunneled |
the Proxy only | from the Proxy's configuration, not the connector's |
fronted is for a deployment that puts Authority and the Proxy behind one
network front, so the connector host needs a single outbound address. The derived
address is taken as-is, port included; the front is responsible for delivering it
to Authority.
tunneled closes the connector's Authority egress entirely: its control-plane
calls ride the reverse tunnel it already holds, and the Proxy copies the bytes
without terminating the TLS, so Authority still authenticates the connector's
certificate. Two calls cannot ride the tunnel, because they happen before it
exists: registration and token-less renewal. Those go to
[endpoints].proxy_join instead:
| Call | Address in tunneled |
Overridden by |
|---|---|---|
| registration (first start) | http://<endpoints.proxy_join> |
[enroll].endpoint |
| token-less renewal | https://<endpoints.proxy_join> |
[enroll].renew_endpoint |
| everything else | the reverse tunnel | unset |
An explicit [enroll] address wins over the derivation; when the derivation
applies, the connector logs the address it settled on. Where the gateway carries
the join on its client-facing port ([proxy].public_join), both
addresses are that port and Authority advertises them, so
gdsgate join writes them into the file and no derivation
is involved. With neither proxy_join nor [enroll].endpoint, a tunnelled
connector refuses to start, since it would otherwise come up with no way to
obtain an identity and report a dead control plane at the first session.
tunneled ties the connector's control plane to its tunnel: while the tunnel is
down, Authority calls fail, and calls that fail are denied, never permitted by
default. That is the trade the mode makes for a single outbound address. A busy
tunnel is not part of the trade: the control-plane stream is not charged to
[proxy].max_streams_per_tunnel, so a connector at its stream cap
still reaches Authority.
In fronted the connector also sends a role-specific TLS server name, so a front
that routes by server name can tell the two destinations apart on one address:
auth.gdsgate.internal on the Authority control-plane channel,
tunnel.gdsgate.internal on both legs of the reverse tunnel (gRPC and the
WebSocket fallback). direct and tunneled stay on the shared name.
Four names exist in total:
| Name | Belongs to |
|---|---|
gdsgate.internal |
every certificate: the shared name, always present |
auth.gdsgate.internal |
the Authority control plane |
proxy.gdsgate.internal |
the Proxy's client-facing listener |
tunnel.gdsgate.internal |
the Proxy's reverse-tunnel listeners |
The front needs a route for the shared name as well. Token-less renewal
([enroll].renew_endpoint) pins gdsgate.internal rather than the
role name, on purpose: it is the one call a node makes while its own identity is
expiring, and it must not additionally depend on the other side having already
picked up its role name. A front configured for the three role names alone passes
registration and then fails renewal roughly a day later. Send gdsgate.internal
to the Authority control plane, next to auth.gdsgate.internal.
A certificate is only reissued on enrollment or renewal, never on CA rotation, so
a node keeps serving its old certificate, with the shared name only, until its
24-hour identity refreshes. Switch a client to a role name only after the node it
dials has renewed; until then the shared name is the one that verifies.
gdsgate doctor on the node reports when its transport identity expires, which
is when the new names arrive.
GDSGATE_CONNECTOR_AUTH_TRANSPORT overrides the value; an unrecognised one is
ignored and leaves the configured mode in place.
Tunnelled connectors and Authority failover¶
With authority_failover configured, the connector names no address:
the Proxy chooses, and it chooses the instance its own connection pool is
currently talking to, which is the one that last answered a leader-only call. The
choice is made when a relay stream is opened, not when the tunnel registers, so a tunnel that has been up for days still gets a stream to the
current leader.
During a failover the sequence is:
- Leadership moves. The Proxy has not noticed yet: what it relays is the connector's own TLS session, and it terminates none of it, so a refusal inside that session is invisible to it.
- The connector's next call goes to the instance that stepped down and is refused with "unavailable". The connector discards the relayed channel, so its next call asks for a fresh stream instead of reusing that one.
- The Proxy's own next leader-only call finds the new leader, and its pool moves.
- The connector's next relay stream is opened against the new leader.
Steps 2 and 3 are independent, so a few of the connector's calls can be refused across a failover. The callers that must not lose data (audit shipping, recording upload) retry with backoff; the callers that gate a session deny it, as they do for any unreachable Authority. A Proxy serving traffic notices within a request or two; a completely idle one notices at the next refresh of its session-signing certificate.
A connector on direct or fronted is not affected: it has its own Authority
address and does not use the relay.
[[connector.backends]]: one resource per entry¶
cockroach kubernetes llm mcp mysql postgres ssh tcp web web-egress
Each row separates the serde type, source default or derived value, and the
backend kind(s) that consume it. unset means Option::None or no entry; it
is distinct from an explicit empty collection or disabled boolean. required
means startup requires the field for the named backend path. A field listed as
ignored is not consumed on that backend path. The row states whether a
non-default value is refused, warned, carried inert, or silently ignored; do
not infer validation from the source default alone.
| Key | Type | Default / unset | Applies to | Purpose |
|---|---|---|---|---|
resource |
string | unset; required | every backend entry | The id clients request and the Proxy routes on. |
kind |
enum | unset; required | every backend entry | One of the ten connector backend kinds. The catalog resource vocabulary is narrower; see the discovery boundary. |
addr |
optional string | unset; required except SSH model A and MCP alternate forms | SSH model B, TCP, Postgres, Cockroach, MySQL, MCP socket, LLM | Backend host:port or LLM base URL. Required for the applicable backend kinds, except SSH model A and MCP forms using command or url. |
command |
array of strings | []; required when MCP has neither addr nor url |
MCP only | The MCP server to run as a child process, argv-style: the program by absolute path, then its arguments. One of the three ways of naming this backend's server, alongside addr and url; naming two of them is refused at startup, and so is naming none. The connector speaks the same newline-delimited JSON-RPC over the child's stdin and stdout, and every gate below applies unchanged. See Running the MCP server as a child process. |
command_env |
map of strings | {} |
MCP with command |
Environment variables the command server is given, on top of the small set every process needs (PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ, USER). The child does not inherit the connector's environment. Refused at startup without command. |
url |
optional string | unset; required for Web | MCP or Web | The remote MCP server's endpoint, spoken to over Streamable HTTP, where every message of a session is its own HTTP POST to this URL. Mutually exclusive with addr and command. See A remote MCP server over HTTP. On a web backend it is instead the base URL of the application, and it is required: a web resource with no upstream is a row in the catalog that resolves to nothing. It is also the whole of where a request goes, since the client's own Host is not routed on and is not forwarded, so the application keeps writing its own domain into its own links. Neither form carries a query or fragment: the caller's request path is appended to it. |
ca_path |
optional string | unset; platform roots when optional | Kubernetes (required), LLM, MCP with url, Web |
PEM CA bundle verifying the upstream's serving certificate. Required for kubernetes, optional elsewhere, and unset means the platform roots. Refused on an mcp backend that names no url, which carries no TLS to anchor. |
allow |
array of strings | []; WebEgress requires nonempty |
WebEgress only | The host names a web-egress backend may be asked to reach, and the whole of what it may reach. Required, and refused empty: read as "everything" it would be a way out of the protected zone opened by leaving a line out, and read as "nothing" it would be a resource that exists, answers, and refuses every request. A leading *. covers one label, exactly as a TLS wildcard does (*.githubusercontent.com admits raw.githubusercontent.com, and neither githubusercontent.com itself nor a.b.githubusercontent.com); a * anywhere else, a bare *, or an entry that is not a host name (a URL, a host:port) is refused at startup. See the web-egress tab below. |
inspect |
bool | false |
WebEgress only | Whether this way out reads what it carries. Default false: a CONNECT is a channel, decided once on the name and the port and carried without being read, costing the capability web:tunnel. true says the programs behind this way out have their TLS ended at their own endpoint, by a root generated for the run and trusted nowhere else, so what reaches the connector is the requests themselves, each decided by method and path and costing web:read / web:write in place of web:tunnel. See Reading what a way out carries. |
routed_ranges |
array of strings | [] |
WebEgress only | The address ranges this way out may dial although nothing has allocated them to hosts on the public internet. Default empty, which is the answer for almost every deployment: an address is dialled only where it is global unicast and outside every range the IANA special-purpose registries have taken back, so loopback, the private ranges, carrier-grade NAT, 169.254.0.0/16, documentation and benchmarking space, fec0::/10, 2001:db8::/32, and anything nobody has been handed at all are refused whichever family they arrive in. An entry is address/prefix (10.0.0.0/8, fec0::/10) and states that this connector's own network really routes that range; bits set below the prefix, and a range reaching loopback, the unspecified address or multicast, are refused at startup. allow still holds either way: a name is admitted by it before any of its addresses are looked at. See the web-egress tab below. |
forwarded_headers |
bool | false |
Web only | Whether a web application is told it is behind a proxy. Default false, which is what makes an application reached through this gateway see an ordinary client on its own domain: its own Host, its own links, its own cookies. true is an operator's decision made for the application, and X-Forwarded-For / -Proto / -Host then carry this connector's own observations, never the client's claims about itself, which are dropped either way. |
identity |
enum | none |
Web only | What a web application is told about who is calling: none (default, where the caller's own Authorization travels untouched), signed (a short-lived Gdsgate-Identity assertion Authority signs, naming the caller, their groups and the one resource it is good at), or headers (X-Remote-User / X-Remote-Groups, unsigned). Either of the last two drops the caller's own Authorization unconditionally, since an application handed both would take its own. See Telling the application who came. |
identity_headers_trust_the_network |
bool | false |
Web only | The claim an operator has to make explicitly before identity = "headers" is served: that this application cannot be reached except through this gateway. Nothing verifies those two headers, since anything that can open a socket to the application can write them itself, so this is the whole of the shape's security. It is worded as a statement rather than a switch so a line copied out of somebody's example does not turn it on by accident. identity = "headers" without it, and this set without identity = "headers", both refuse to start. |
allow_encoded_separator |
bool | false |
Web only | Whether a path segment on a web backend may percent-encode a /. Default false: judged decoded such a segment is two path segments and forwarded raw it is one, and this gateway is about to decide on that path. true admits it raw and whole, for an API that addresses objects that way (a GitLab project is group%2Fproject in its own URLs). true does not admit a decoded form that also carries a dot segment (%2e%2e%2f), which is refused either way. |
websocket |
bool | false |
Web only | Whether a web application may have a WebSocket channel opened through it. Default false, the same default allow_port_forward takes on the Kubernetes lane and for the same reason: an upgrade is the one shape of traffic on this lane where the per-request gate runs once, at the handshake, and then stands aside for as long as the channel lives. true still decides the handshake under httpRequest, and context.upgrade says it is one, so a rule can forbid a channel on a route it otherwise permits reading; nothing on the channel is decided again once it opens, and it runs until a peer closes it or the certificate that admitted the session expires. |
max_request_bytes |
u64 (bytes) | 0; no ceiling |
Web and WebEgress | The largest request body this backend carries at all. 0, the default, is no ceiling: the body streams to the far end a chunk at a time rather than being held, so no size here is bounded by what the connector can keep in memory. Where it is set, a client that declared its length is refused 413 before the far end is dialled, and a client that declared none, a chunked upload, is carried up to the ceiling and then cut off, which leaves the far end with the beginning of a request that was abandoned. Refused at startup on any other kind. |
routes |
array of tables | [] |
Web only | The named path families a request is matched into before it is decided; see [[connector.backends.routes]]. Empty means every request carries context.route == "", and only a rule written without naming a route can permit anything. |
audit |
optional enum | unset; derived sampled on Web and full on WebEgress |
Web and WebEgress | How much of this backend's traffic reaches the audit chain request by request: sampled or full. Default is sampled on web, where a page is a hundred assets, and full on web-egress, where a request is a program's own call. A route may override it for its own family in either direction on web. See The web lane. |
decision_cache_ttl_secs |
optional u64 (seconds) | unset; derived 5 for Web |
Web only | How long a per-request permit is reused before the gate is asked again. None uses the lane's default of five seconds; 0 decides every request on its own. It never applies to a refusal, since only a permit is ever reused, so revoking access takes effect up to this many seconds late. Refused on web-egress, which caches nothing: a decision there is about a host rather than a route, and reusing it would turn one allowed host into a permit for every host. |
credential_file |
optional string | unset | LLM; MCP with url |
Protected file read and held by the connector for an upstream credential. On an LLM substitution lane it is the organisation's provider key, which the provider sees instead of the caller's credential. On remote MCP it is the token the server wants, and there is no caller credential to substitute. Unset selects LLM pass-through and sends no provider credential for remote MCP. |
credential_header |
optional string | unset; derived authorization when credential_file is set |
LLM; MCP with url |
The header the credential travels in. A header the lane writes itself is refused at startup. Refused without credential_file. |
credential_format |
optional string | unset; derived Bearer {credential} when credential_file is set |
LLM; MCP with url |
The value template, which must carry {credential} exactly once. A header the lane writes itself is refused at startup. Refused without credential_file. |
credential |
optional string | unset; any value refused | every backend entry; reserved refusal | An inline provider key. Always refused at startup so a mistaken configuration cannot silently become pass-through. Write the key to a protected file and name it in credential_file. |
allowed_request_headers |
array of strings | [] |
LLM substitution; requires credential_file |
Request headers a caller may send that substitution passes on, on top of the content-negotiation defaults (accept, accept-encoding, content-type). An allow-list rather than a deny-list, because "no caller-chosen credential reaches the provider" cannot be kept by listing the credential headers somebody might use. A provider header a service needs (anthropic-version) is named here. ["*"], alone in the list and never beside names, passes on every header this lane is able to, keeping back only the ones it strips on every call and credential_header itself. The connector states that widening at start-up, because a credential the caller sends under any other name then travels with the call. Requires credential_file. |
allowed_query_params |
array of strings | [] |
LLM substitution; requires credential_file |
Query parameters a caller may send under substitution. Empty refuses a call carrying any query string, because some services take a key in the query and an unrestricted list would let a caller authenticate as itself around the substitution. ["*"] passes every parameter on, with the same start-up statement and the same consequence. Requires credential_file. |
allowed_models |
array of strings | [] |
LLM only | Models this backend serves at all, a per-resource ceiling under the Cedar decision and the counterpart of allowed_tools. Empty leaves policy as the whole boundary, and llmCall is deny-by-default. |
record_request |
optional string (record level) | unset; derived digest |
LLM, Web, WebEgress | How much of what a caller sends is written into the session recording: off, digest (default), redacted, full. Set independently of record_response. On llm this is what the model received; on web and web-egress it is the HTTP request. Neither controls token accounting on llm, nor the audit chain's own request rows on the web lane. |
record_response |
optional string (record level) | unset; derived digest |
LLM, Web, WebEgress | How much of what the backend answers with is written into the session recording: off, digest (default), redacted, full. Set independently of record_request. On llm this is what the model answered; on web and web-egress it is the HTTP response, including a channel's frames. Neither controls token accounting on llm, nor the audit chain's own response rows on the web lane. See The model lane and Recording the exchange. |
allowed_tools |
array of strings | [] |
MCP only | The tool allow-list enforced on tools/call. Ignored otherwise, with a warning when nonempty. |
enforce_tool_policy |
bool | false |
MCP only | false uses allowed_tools only. true also authorizes each tools/call against the Cedar mcpCallTool action and filters tools/list with viewTools, so the effective decision is Cedar and the allow-list together, fail-closed on deny or an unreachable Authority. |
allowed_input_requests |
array of strings | [] |
MCP only | Server-initiated asks this backend may make of the client: sampling/createMessage, elicitation/create, roots/list. Empty, the default, carries none of them. Every ask is audited either way. A name outside the three is refused at startup. |
allow_elicitation_url |
bool | false |
MCP only | false refuses an elicitation/create in mode = "url", a link the server chose rendered to the person, even where the ask itself is permitted. Setting it without naming elicitation/create in allowed_input_requests is refused at startup. |
pin_tool_descriptors |
bool | false; true refused on connector backends |
every backend entry; catalog owns the active setting | Descriptor pinning moved to the catalog resource row. A true backend value is refused at startup; set it on the matching [[discovery.resources]] entry instead. |
refuse_unfiltered_tool_listing |
bool | false |
MCP only | What happens to a tools/list result whose per-caller viewTools decision could not be obtained, because Authority is unreachable. false (default) forwards the listing as the server served it and records the failure as mcp.tool_listing_undecided. true answers the caller with a JSON-RPC error instead, so no name of a tool it may not see leaves the gateway. The call gate is fail-closed either way. Requires enforce_tool_policy; set without it, the connector refuses to start. See When Authority cannot answer. |
decider_url |
optional string | unset | MCP only | An external decider asked, synchronously, before anything on a data path (tools/call, resources/read, prompts/get) reaches the server: one POST per call to this URL. The contract is either allow or block, so it can only add a refusal to what policy already permitted, and it is never asked about a call another gate already refused. Mutually exclusive with decider_command. Independent of enforce_tool_policy. See The external decider. |
decider_command |
array of strings | [] |
MCP only | The same decider as a program this node runs, argv-style, program by absolute path: one process per call, fed the request as one JSON line on stdin. Measurably more expensive than decider_url and running as the connector's own user; see The external decider. |
decider_command_env |
map of strings | {} |
MCP with decider_command |
Environment variables a decider_command is given, on top of the same small set a command server gets. Refused at startup without decider_command. |
decider_timeout_ms |
optional u64 | unset; derived 2000 when a decider is configured |
MCP with a decider | How long a caller's call is held while the decider thinks. Default 2000. 0 and anything over 60000 are refused at startup. A deadline that passes is a decider that did not answer. |
decider_fail_closed |
optional bool | unset; derived true when a decider is configured |
MCP with a decider | What a decider that did not answer costs. Default true, which refuses the call. false carries it and records that nobody decided it. Refused at startup without a decider to configure. |
cage |
optional table | unset | MCP with command or decider_command |
The cage the programs this backend runs on this node, the command server and the decider_command, are confined by. One table for both. Absent means no cage. Refused at startup on a backend that runs neither. See The cage. |
decider_ca_path |
optional string | unset; platform roots | MCP with decider_url |
PEM CA bundle verifying an https decider_url. Its own key rather than ca_path, because a backend may name both a remote server and a decider. Unset means the platform roots. Refused at startup without decider_url. |
api_url |
optional string | unset; required | Kubernetes only | Cluster API base URL, for example https://k8s:6443. |
token_path |
optional string | unset; required | Kubernetes only | Service-account bearer token the connector presents to the API, as the impersonator. |
allow_port_forward |
bool | false |
Kubernetes only | false refuses kubectl port-forward. true opts the backend in; port-forward opens a recording-blind TCP tunnel into the pod network. |
allowed_backend_hosts |
array of patterns | []; empty means unrestricted |
TCP, Postgres, Cockroach, MySQL, MCP, LLM, Web | SSRF border on the backend address: addr, or the authority of url with the scheme's default port when it names none, must match one of these host:port / host:lo-hi / *:port / CIDR:port patterns or the connector refuses to start. Empty means no restriction. An mcp backend that sets command has no address to constrain. It is not consumed for SSH, Kubernetes, or WebEgress. |
allow_unpinned_upstream |
bool | false |
SSH model B only | false refuses to start a jump-host session when no host-key fingerprint is pinned. Set it to true only when per-host-key pinning is impossible. |
login_user |
optional string | unset; derived root |
SSH model B only | POSIX user on the downstream host, which becomes the principal of the OpenSSH certificate Authority signs per connection. Defaults to root. |
host_key_fingerprints |
array of strings | [] |
SSH model B only | Pinned SHA256:<base64> fingerprints of the downstream sshd's host key, the form ssh-keyscan prints. The connector refuses any other host key. Empty uses trust-on-first-use only when allow_unpinned_upstream = true, and logs a warning; the default false refuses the unpinned session. |
host_key_fingerprints_file |
optional string | unset | SSH model B only | Path to a file with one SHA256:<base64> per line, appended to host_key_fingerprints at startup. Useful when an external script populates the file, for example gdsgate ca learn-host-key. |
allow_local_forward |
array of patterns | []; empty means loopback only |
SSH model A and B | Layer-1 SSRF border for ssh -L. See Forward gating. Empty means loopback only. |
allow_remote_forward |
array of patterns | []; empty disables |
SSH model A and B | Layer-1 SSRF border for ssh -R. See Forward gating. Empty disables -R. |
allow_agent_forward |
bool | false |
SSH model A | Layer-1 switch for ssh -A. false refuses agent forwarding before Authority is asked; the Connector creates no socket and the session's programs see no SSH_AUTH_SOCK. true offers the capability to the backend, and the Cedar action sshForwardAgent then decides per user and session. Weigh it: while the session lives, anything that reaches the forwarded socket authenticates with the client's keys, including root on the Connector host. Model B serves no agent request at all. |
db_roles |
array of tables | [] |
Postgres and Cockroach in session_role |
Session-role profiles: a logical role name Cedar can authorise through context.db_role, bound to a real pre-created DB user. read_only = true declares that the DB role holds no write grant, and gdsgate doctor checks the database against the declaration and fails if it does. |
auth_mode |
enum | passthrough |
Postgres and Cockroach; MySQL must remain passthrough |
passthrough (default), where the client brings its own DB credentials and the connector byte-relays. session_role, where the connector connects as service_account and issues SET ROLE to the policy-chosen role. |
enforce_query_categories |
bool | false |
Postgres and Cockroach | false keeps the query log observe-only. true classifies each statement and refuses any whose category the policy (dbQuery) does not permit. Always on for session_role; this flag adds it to passthrough. Ignored for MySQL and other kinds. |
statement_timeout_ms |
optional u64 | unset; backend default | Postgres and Cockroach in session_role |
Server-side statement_timeout the connector sets on the session, in milliseconds. The client cannot raise it. Unset uses the backend default. |
settable_parameters |
array of strings | [] |
Postgres and Cockroach in session_role |
Extra session parameters a client may SET, on top of the built-in list of driver parameters (client_encoding, search_path, TimeZone). A restricted session may set those and nothing else. Parameters the connector manages (role, application_name, statement_timeout, default_transaction_read_only) are refused even if named here. |
allow_fastpath_function |
bool | false |
Postgres and Cockroach restricted sessions; MySQL carries the field but has no restricted gate | false (default) refuses the fast-path FunctionCall message in a restricted session, which calls a function by OID outside SQL and so cannot be category-gated. true re-enables it for a legacy client, and the call is audited but not gated. No effect on observe-only passthrough. |
service_account |
optional string | unset; required for session_role |
Postgres and Cockroach in session_role |
Service-account connection string, no password, for example postgres://gdsgate_sa@host/db. Required for session_role. |
service_account_password_file |
optional string | unset; required for session_role |
Postgres and Cockroach in session_role |
Path to a file holding the service-account password (vault or secret mount). Required for session_role. |
service_account_tls_ca_file |
optional string | unset; system trust store | Postgres and Cockroach in session_role |
PEM CA bundle verifying the backend's TLS certificate when the service-account DSN sets sslmode=verify-full, for example an RDS or CloudSQL root bundle. Unset uses the system trust store. |
discovery |
optional table | unset | Postgres and MCP | Live-discovery probe; see [connector.backends.discovery]. |
admin |
optional string | unset; reserved | every backend entry; no current consumer | Future use: admin credential for the resource. Not consumed in this release. |
credential_file, token_path, and service_account_password_file each
accept the secret-reference notation in
place of a bare path; each is read once, at startup.
[[connector.backends.routes]]: named path families¶
One table per named family of paths on a kind = "web" backend. Matched in the
order they are declared: the first pattern that claims a request wins, so a
catch-all written first makes every route beneath it unreachable. Ordinary
overlap (/admin/* and /* are both true of /admin/users) is not refused.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
name |
string | unset, required | Web backend route | The word context.route carries to policy. It must be non-empty and unique within the backend. |
match |
array of strings | unset, required | Web backend route | Path patterns claiming this family. Each begins with /; * matches any run, including none. |
audit |
optional enum | unset; inherits backend audit |
Web backend route | How much of this route's traffic reaches the chain. Unset inherits the backend value. |
Only the path is matched. A query string is the application's own vocabulary,
ordered by whoever wrote the link and absent as often as it is present, so it is
never part of a pattern's meaning. A request no declared route covers carries
context.route == "" into the decision, which deny-by-default answers without
anything here having to be written:
[[connector.backends.routes]]
name = "admin"
match = ["/admin/*", "/api/admin/*"]
[[connector.backends.routes]]
name = "dashboards"
match = ["/d/*"]
Per-kind reference¶
[[connector.backends]]
resource = "jump-host"
kind = "ssh"
# No addr: the connector is the SSH server.
allow_local_forward = ["redis.internal:6379", "10.0.0.0/8:5432"]
allow_remote_forward = ["127.0.0.1:8000-9999"]
The connector terminates the SSH session, spawns a PTY under its own OS
user, and records the terminal stream in asciicast v2. SFTP, PTY, exec,
agent forwarding (-A), -L, and -R all work; all three forwarding
capabilities are subject to the
two-layer forward gating, and -A is off until
allow_agent_forward = true says otherwise.
The connector's SSH host key is persistent across sessions: at first start
it is generated and stored under [enroll].state_dir as
ssh_host_ed25519_key, mode 0600. Re-using the same key keeps a client's
~/.ssh/known_hosts entry valid.
[[connector.backends]]
resource = "jump-fleet"
kind = "ssh"
addr = "sshd-target:22"
login_user = "deploy"
host_key_fingerprints = ["SHA256:<base64-fingerprint>"]
# Or load fingerprints from a file populated by deploy-time scripts:
host_key_fingerprints_file = "/etc/gdsgate/sshd-target.fp"
The connector terminates the client's SSH, to record it, then opens its own
SSH session to the downstream sshd and relays every channel.
Authentication to the downstream sshd is by an OpenSSH user certificate
Authority signs per connection with its Onward SSH CA, with
principal = login_user. Pin the downstream's host key; the explicit
allow_unpinned_upstream = true escape hatch is for development only.
[[connector.backends]]
resource = "prod-db"
kind = "postgres"
addr = "10.0.0.5:5432"
[[connector.backends.db_roles]]
name = "readonly"
db_user = "ro_login"
[[connector.backends.db_roles]]
name = "writer"
db_user = "rw_login"
In the default passthrough mode the connector forwards the database wire
stream to addr, taps the client-to-server direction to emit a structured
query log into the audit chain, and the client authenticates to the
database with its own credentials.
db_roles declares session-role profiles: a logical role name Cedar
authorises through context.db_role, bound to a real pre-created DB user.
The configurator that owns the database admin credential is the source of
truth for these mappings.
Session-role impersonation: set auth_mode = "session_role" so the
connector connects under one service account and issues SET ROLE to the
policy-chosen role. The database then needs no per-user accounts, and the
audit log still records the real principal plus the role:
[[connector.backends]]
resource = "prod-db"
kind = "postgres"
addr = "10.0.0.5:5432"
auth_mode = "session_role"
service_account = "postgres://gdsgate_sa@10.0.0.5:5432/appdb"
service_account_password_file = "/run/secrets/gdsgate_sa_pw"
[[connector.backends.db_roles]]
name = "analyst_ro" # the logical name Cedar authorises
db_user = "analyst" # the real role SET ROLE switches to
The service account needs GRANTs for each db_user it may assume;
gdsgate_sa should own nothing else, and should not be a superuser, since
a superuser is implicitly a member of every role and bypasses every grant,
which leaves SET ROLE as the only thing narrowing the session. A client
that tries to issue SET ROLE or SET application_name itself is refused.
The DBA-side correlation key is
application_name = gdsgate:<principal>:<session_uid>.
CockroachDB: kind = "cockroach". Everything on this page applies
unchanged, session_role and enforce_query_categories included, and the
default port is 26257. One exception:
gdsgate doctor's catalog grant probe is
PostgreSQL-only and reports a cockroach backend as unchecked; the
connector's session-role path itself supports both PostgreSQL and
CockroachDB over the PostgreSQL wire protocol.
A reading profile must be a reading role. For a capability without
write, ddl or dcl the connector sets
default_transaction_read_only = on, which is defence in depth rather
than the boundary: the parameter is USERSET, so the session could clear
it if the gateway ever let a statement through that does. The boundary is
that the role holds no write grant to begin with. Declare the intent and
let gdsgate doctor check the database against it:
[[connector.backends.db_roles]]
name = "analyst_ro"
db_user = "analyst"
read_only = true # doctor fails if `analyst` can write
Three ways a read-only role turns out to be able to write, none of them
visible in the connector's own configuration, and all of them reported by
doctor:
GRANT UPDATE <privilege> TO PUBLICon one table, sincePUBLICreaches every role in the database, reading ones included;- the role owns a table, which carries full rights with no ACL entry to audit;
- a
SECURITY DEFINERfunction the role may execute writes with its owner's rights, whatever the caller holds. Only the read-only transaction default stops that one, which is why it is worth keeping even though it is not the boundary.
For a managed database, set sslmode=verify-full in the service_account
DSN and point service_account_tls_ca_file at the provider's root bundle
to verify the backend's TLS. Set enforce_query_categories = true, or
rely on session_role, which always enforces, to gate each statement by
category. See
Policy → Per-statement database authorization
and the database session roles pattern.
Live discovery (Postgres): a [connector.backends.discovery] block makes
the connector periodically enumerate the host's databases under a
read-only account and publish them to Authority, so gdsgate ls shows
prod-db.<database> sub-resources without hand-listing them. The
inventory is observational: it surfaces resources in listings and never
grants access, since the per-connect connect gate is the authority.
[connector.backends.discovery]
mode = "list_databases"
connection = "postgres://disc_ro@10.0.0.5:5432/postgres"
password_file = "/run/secrets/disc_ro_pw"
interval_secs = 300
allowlist = ["app_*", "analytics"] # optional globs
denylist = ["template_*"]
Use a separate, least-privilege account (disc_ro, only SELECT on
pg_database) rather than the SET ROLE service account. A probe failure
keeps the prior inventory, since the connector never publishes an empty
snapshot that would read as "all deleted". Field reference:
[connector.backends.discovery].
[[connector.backends]]
resource = "prod-cluster"
kind = "kubernetes"
api_url = "https://kubernetes.default.svc:6443"
ca_path = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
The connector reverse-proxies the cluster API, authenticating with the
service-account token and CA bundle, and impersonates the resolved
identity on every request. Client Authorization and Impersonate-*
headers are stripped and replaced, so the user cannot pick who they
impersonate. Every request is authorized on its own (kubeRequest,
carrying context.read_only for the verbs that only read); exec and
attach are recorded. kubectl port-forward is refused unless
allow_port_forward = true is set, since it opens a recording-blind
tunnel into the pod network.
Raw TCP byte forward to addr. Authorisation is connect over
TcpService::"<resource>". Used for Redis, internal HTTP, gRPC services,
anything plaintext.
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
allowed_tools = ["search", "read_file"]
enforce_tool_policy = true # also gate each call with Cedar mcpCallTool
# What the server may ask of the client. Empty (the default) = nothing.
allowed_input_requests = ["elicitation/create"]
allow_elicitation_url = false # no server-chosen link in front of the user
# Optional: discover the server's tools to filter `tools/list` per identity.
# (Discovered tools are not added to `gdsgate ls`; only the server is.)
[connector.backends.discovery]
mode = "list_tools"
The connector forwards JSON-RPC verbatim, and each tools/call is gated
against allowed_tools before it reaches the backend, with every tool
call audited. A denied tool returns a JSON-RPC error to the client. With
enforce_tool_policy = true the call is also authorized against the Cedar
mcpCallTool action and tools/list is filtered with viewTools; see
Policy → MCP per-tool policy.
The other direction is gated as well. An MCP server can ask the client to
complete text with its own model (sampling/createMessage), to put a
question or a link in front of the person (elicitation/create), or to
list its filesystem roots (roots/list), in the pre-2026-07-28 dialect as
a server-to-client request, and from that revision as an inputRequests
field on a result. Both forms are read, both are audited, and an ask is
carried only where allowed_input_requests names it. A refused ask is
answered with a JSON-RPC error: to the server for a server-to-client
request, to the client for a result it was riding on.
Two of the revision's own rules are enforced ahead of that list, so they
hold however permissive the configuration is: an ask may ride only on the
answer to prompts/get, resources/read or tools/call, and only for a
capability the client declared on that same request. A session also pins
the protocol revision to the first one a request names; the revision it
spoke is recorded on the session's session.close.
The instructions an MCP server returns at its handshake are free text
that lands in the model's context. gdsgate relays them unchanged and
records their size as mcp.instructions. Stripping them would not be a
boundary, since the same text fits a tool description, a tool result or a
resource, and removing a protocol field silently would read as the
server's own bug.
[[connector.backends]]
resource = "docs-tools"
kind = "mcp"
# Instead of `addr`, never alongside it.
command = ["/usr/local/bin/mcp-filesystem", "--root", "/srv/docs"]
command_env = { MCP_LOG_LEVEL = "warn" }
allowed_tools = ["search", "read_file"]
enforce_tool_policy = true
Almost no MCP server listens on a port: the usual form is a program
started by whoever talks to it, speaking the same newline-delimited
JSON-RPC over its stdin and stdout. command serves that form.
Everything above applies to it identically: the per-call Cedar decision,
tools/list filtering, descriptor pinning, the server-initiated ask gate,
the session envelope and the pinned protocol revision are one
implementation over a pair of byte streams, and it does not know which
transport produced them. session.close records which one did, as
transport=stdio or transport=socket.
One server per session, started when the session opens and ended when it
closes: a stdio server is one conversation per process, and sharing one
would interleave two callers on one pipe. A
[connector.backends.discovery] probe starts a copy of its own on each
interval, for the same reason.
[[connector.backends]]
resource = "models-prod"
kind = "llm"
addr = "https://api.example.com" # base URL, not host:port
ca_path = "/etc/gdsgate/upstream-ca.pem" # optional; platform roots if unset
# Substitution: the organisation's key, presented instead of the caller's.
# Omit credential_file for pass-through (the caller's own key travels).
credential_file = "/run/secrets/models-prod"
credential_header = "authorization" # default
credential_format = "Bearer {credential}" # default
allowed_request_headers = ["anthropic-version"]
allowed_query_params = ["api-version"]
allowed_models = ["gpt-4o", "gpt-4o-mini"] # empty = policy is the boundary
# Prompts and answers are content. digest/digest is the default.
record_request = "digest"
record_response = "digest"
A model service, reverse-proxied with a policy decision on every call. The
connector terminates HTTP here, because it cannot decide about a stream it
only relays and the model's name is in the request body, reads that name,
evaluates the Cedar llmCall action, and only then originates TLS onward
to addr. See The model lane for what the two modes
mean and what is recorded.
What stands behind addr is the operator's business: the provider's own
API, or a model gateway of theirs in front of several. gdsgate provides
the identity, decision and proof rather than a collection of provider
adapters.
[[connector.backends]]
resource = "grafana"
kind = "web"
url = "https://grafana.internal:3000"
# Optional: named path families, matched before anything is decided.
[[connector.backends.routes]]
name = "admin"
match = ["/admin/*", "/api/admin/*"]
[[connector.backends.routes]]
name = "dashboards"
match = ["/d/*"]
The connector terminates HTTP and reverse-proxies each request to url,
re-forming it on its own side: neither the client's Content-Length nor
its Transfer-Encoding is copied onward. The outgoing Host is the base
URL's own, never the client's, so the application keeps writing its own
domain into its own links; forwarded_headers decides whether it is told
this connector's own observations of the caller on top of that.
Two gates: connect opens the session over the backend's WebApp, and
httpRequest decides every request inside it, carrying context.route,
the name the canonicalised path matched, or "" where nothing did. See
Policy → HTTP(S) application routes.
The caller's own cookie header reaches the application unchanged in
every case, since a web application's session is a cookie and stripping it
would log the caller out rather than narrow anything. authorization
travels unchanged too under the default identity = "none"; where the
backend substitutes identity it is dropped and Authority's own statement
goes in its place, see
Telling the application who came.
set-cookie comes back too, with its Domain cut, so the cookie sticks
to whatever name the browser is actually on; Secure, HttpOnly,
SameSite and Path travel untouched. A 3xx the application answers
with is handed to the client exactly as it arrived, its Location
rewritten the same way when it names the application's own authority, as a
reference relative to the request the browser made, and left alone when it
names anywhere else; this connector never follows a redirect itself.
Two refusals are the lane's own rather than policy's: a path segment that
percent-encodes / is 400 unless allow_encoded_separator admits it,
and a WebSocket handshake is 501 unless websocket = true is set on the
backend. Enabled, the handshake is still decided under httpRequest, and
context.upgrade says it is one, so a rule can forbid a channel on a
route it otherwise permits reading; nothing after the 101 is decided,
and the channel carries whatever it carries until a peer closes it or the
certificate that admitted the session expires. A request body is carried
to the application a chunk at a time and is not bounded unless
max_request_bytes says so; an application that has not begun answering
within 30 seconds is 504, and the deadline is on the response head only,
so a slow stream and a large download are not cut off in either direction.
See The web lane.
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["github.com", "*.githubusercontent.com", "pypi.org"]
The permission to leave the protected zone, rather than an application
inside it: a request names its own target, and allow is the whole of
what this way out admits. Catalogued and authorized like any other
resource, through connect over its own WebEgress entity, audited open
and closed, and inside that session every request is decided again on
httpRequest, exactly as on a web application.
A client speaks to this the way HTTP defines for a forward proxy, and
there are exactly two shapes: an absolute target in the request line
(GET http://host/path) and CONNECT host:port. Origin form
(GET /path) names no host and is refused 400 with a message naming
both shapes; the ordinary cause is a program pointed at this endpoint as a
base URL rather than as a proxy (see
CLI → Endpoints: what the program is given).
allow is checked before Authority is asked, because a name this way out
cannot reach at all is not a question policy has an opinion on. The
wildcard rule is the field table's above; every shape that could not mean
what it looks like (*, *.com, a * anywhere but the whole leftmost
label, a URL, a host:port, a blank entry) is refused when the connector
starts, rather than at the first request that would have matched it.
The name is resolved once, and the socket is opened on the address that
resolution produced. Checking a name and then handing the name to
connect is two resolutions with a decision between them, and a second
DNS answer landing in that window would make allow decorative; there is
no such window here. Every address the name resolves to is then judged by
what it is rather than by which list it is on: an address is dialled where
it lies in the space allocated to global unicast and outside every
range that space has been taken back out of, and anything else is refused,
including a range nobody here has heard of. What that closes in IPv4 is
0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16 (where the cloud metadata service answers),
172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24,
192.168.0.0/16, 198.18.0.0/15 (the resident client's own pool),
198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 and
255.255.255.255; in IPv6, ::, ::1, 64:ff9b:1::/48, 100::/64,
2001::/23, 2001:db8::/32, 3fff::/20, 5f00::/16, fc00::/7,
fe80::/10, fec0::/10 and ff00::/8, together with the whole of the
address space outside 2000::/3, which has not been handed out to hosts
at all. The four IPv4-in-IPv6 forms (v4-mapped, v4-compatible, 6to4,
NAT64) are unwrapped first and judged as the IPv4 address they carry, so
2002:a9fe:a9fe:: is refused as the metadata service. A range that this
connector's own network genuinely routes is named in routed_ranges, and
that is the only way past the check.
One address inside all that refuses the whole name, since taking
the acceptable half of an ambiguous answer would be this gateway silently
deciding which half to believe. This is what a split-horizon name costs
here: an entry on allow that also resolves to something inside the zone
from this connector's vantage point is refused whole, on purpose, because
the address check holds for a name the operator did admit, which is the
one case it exists for.
context.route is always "" on this half of the lane, since a way out
has no routes, and context.inspected says which of the two shapes was
decided: true for the absolute form, whose path this connector read and
canonicalised, and false for a CONNECT, decided on the name and the
port alone and then carried as a byte stream nothing here reads. A rule
that needs more than a name and a port can require context.inspected and
refuse rather than silently apply to a request it never saw. See
Policy → HTTP(S) application routes.
No permit is reused on this half of the lane. The cache described under The web lane is keyed on a route, and a way out has none: what a decision is about here is the host, and caching it the same way would turn one permitted host into a permit for every host. Every request asks Authority again.
A delegated program pays a verb of its own for CONNECT, web:tunnel,
which web:write does not carry: what follows a CONNECT is a stream
this connector cannot read, and granting it under the write verb would be
"may change things" standing in for "may do anything, unobserved". The
absolute form costs web:read / web:write by method, the same as an
application's traffic.
Audited as http.request per request, with audit defaulting to full
here, and, for a CONNECT, a further http.tunnel row when the channel
ends, with tunnel = "connect" telling it apart from a WebSocket's
websocket.
TLS to the far end verifies against the platform roots only: a way out
reaches the public internet, and an operator-named anchor there would be
an anchor for names this gateway does not own. ca_path is not read on
this kind. The response-head deadline, 30 seconds and the same as an
application's, bounds the absolute form; a CONNECT has no head to wait
for.
Inspecting egress traffic¶
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["github.com", "pypi.org"]
inspect = true
inspect = true turns a CONNECT from a byte stream nobody here reads into a
series of requests decided one at a time, the way the plain-http form always
was. Off by default: TLS the delegated program chose end-to-end is then read by
this gateway, which is a decision an operator makes rather than a
strictly-better setting.
The two ends agree in writing rather than by assumption. Before the program
starts, gdsgate delegate --exec, the one thing that builds the cage this
depends on, asks the way out OPTIONS *, HTTP's own way of asking about the
proxy rather than about a resource, and reads back Gdsgate-Way-Out: inspect
or tunnel; a way out that answers neither, an older connector included, reads
as tunnel. On each CONNECT after that the endpoint declares
Gdsgate-Terminated: tls, saying the stream inside is already plaintext HTTP
for that name. Either side of the mismatch is refused 501 naming the setting:
a CONNECT making the claim against inspect = false, and a plain CONNECT
reaching inspect = true. One consequence of the second refusal: only
gdsgate delegate --exec speaks this exchange, so a CONNECT sent through
gdsgate web proxy or any other plain relay is
refused 501 on a backend with inspect = true. The plain-http form is
unaffected either way, since it never spoke CONNECT.
The TLS ends beside the cage rather than at the connector. The run generates a
certificate root of its own, mints a leaf for the name each CONNECT asked
for, and terminates the TLS itself before handing the connector the requests
that were inside. The key lives in that process's memory only, is trusted by
nothing but the one program the run forked, and is gone when the run ends;
signing under somebody else's name is not a power to keep in the middle of a
cluster, so it is generated at the edge and nowhere else, and there is no method
anywhere that hands the key back out. Ed25519, server authentication only, a
path length of zero: a leaf good for presenting a name and nothing past that,
from a root that cannot delegate its own signing power further. Valid for the
delegation grant's own window, with five minutes either side for clock skew, so
nothing it signed outlives the run that minted it.
What reaches the connector is the requests themselves, decided under
httpRequest exactly as the plain-http form is: context.inspected = true,
context.route == "" since a way out has no routes, and the cost is web:read
/ web:write by method rather than web:tunnel, because a decided CONNECT
opens no channel. Every row says inspected = true, with the method and path
the run actually read. The onward hop is unchanged: the real TLS to the far end
still verifies against the platform roots and the name that was asked for, so
reading what a program sends does not become believing whatever answers.
Two shapes are outside what this carries. A CONNECT to a port that does not
speak TLS-over-HTTP is refused, since there is no shape here for a raw socket,
only requests inside a terminated TLS session; and so is a WebSocket upgrade,
which this endpoint does not offer inside a terminated CONNECT, because what
admits a channel is deciding once and then standing aside, and every request
here is decided on its own. Reach either through the same resource with
inspect left off.
What the delegated program is given to trust the root with is the other half of the split, assembled by the client that terminates the TLS; see CLI → Endpoints: what the program is given. A program that pins its own certificate, or reads none of the trust variables named there (the JVM, .NET), does not work through an inspecting way out, and the run says so up front rather than leaving a TLS error to be diagnosed.
The web lane¶
A kind = "web" backend is one application, reverse-proxied with a policy
decision on every request. Opening the session (connect) and deciding what
happens inside it (httpRequest) are separate evaluations, the same
relationship kubeRequest has to connect, and the session decision is spent
once while the request decision runs again for every one of the fifty to a
hundred and fifty a single page load can make. This section is about that half
of the lane; the way out (kind = "web-egress") shares the request decision and
the audit shape but caches nothing and carries no route, see the web-egress
tab above.
The permit is reused, and the reuse is bounded. A permit is keyed on the caller,
the resource, the route and the method together, and once given is reused for
decision_cache_ttl_secs (default five seconds) without a further round trip to
Authority, which is the trade a lane whose unit of traffic is a page rather than
a command has to make. A refusal is never cached, so the cost is entirely on the
permissive side: revoking access takes effect up to the TTL late, and a decision
that was already refused never stays refused for longer.
Traffic is sampled rather than journalled whole. A row per request would cost
more chain than every other lane's traffic together and would bury the request a
reviewer came looking for among the assets that arrived with it. A successful
GET on a route nobody marked is folded into the session's close row; every
other method, every answer that is not 2xx, and anything on a route the
operator set audit = "full" on, backend-wide or per route, is written as its
own http.request row. What is folded is still counted: the close row carries
requests, bytes_up, bytes_down, the count in each status class
(status_2xx to status_5xx) and rows_folded, the number of rows sampling
replaced, so a quiet session and a summarised one do not read the same.
A channel is opt-in per backend, decided once, and counted apart.
websocket = true on the backend is what admits a WebSocket at all; unset, a
handshake is refused 501, naming the setting. Where it is set, the handshake
is still an ordinary httpRequest decision, and context.upgrade says what it
is, so a rule can permit reading a route and refuse a channel on it. Nothing on
the channel is decided again once it opens: it runs until a peer closes it or
the certificate that admitted the session expires, whichever is first. What it
moved travels on the session's close row as tunnels, tunnel_bytes_up and
tunnel_bytes_down, apart from the request counters, since a channel's frames
are carried without being read. The channel itself is written once, as
http.tunnel, when it ends, naming why: a peer closed it, the transport failed,
or the certificate expired underneath it. The way out's own channel, a
CONNECT, writes the same event, told apart by tunnel = "connect" against a
handshake's tunnel = "websocket".
A handshake is never held in the reused permit, and never reads one back. The
cache two paragraphs up is keyed on (caller, resource, route, method), and a
WebSocket handshake is a GET on the same route a page's other assets are, so a
permit already given for reading that route would otherwise carry a channel
opened on it moments later, with Authority never asked about the channel. A
handshake therefore neither takes a permit from the window nor leaves one in it;
it is decided fresh every time, under context.upgrade. A delegated program
pays a verb of its own for the channel, web:tunnel, which web:write does not
carry, the same distinction the way out draws for CONNECT.
What travels, and what does not. The caller's cookie and authorization
headers reach the application unchanged. set-cookie comes back too, but not
whole: its Domain is cut, so the cookie sticks to whatever name the browser is
actually on rather than the application's own, while Secure, HttpOnly,
SameSite and Path travel untouched. X-Forwarded-For / -Proto / -Host
are added only when forwarded_headers = true, and then they carry this
connector's own observation, never the client's claim about itself. A 3xx the
application answers is handed to the client exactly as it arrived, its
Location rewritten the same way as the cookie's Domain when it names the
application's own authority, as a reference relative to the request the browser
made, and left untouched when it names anywhere else; the connector never
follows one itself.
The body is carried, and the framing around it is this gateway's. Neither the
client's Content-Length nor its Transfer-Encoding is copied onward: the
request that leaves is formed here, so the two ends can never come to disagree
about where one request ends and the next begins, which is what request
smuggling is. The outgoing framing is formed from the length the connector's own
decoder read the inbound body by: a request that arrived with a length leaves
with the same length, one that arrived chunked leaves chunked, and one that
arrived with both arrives with neither, since a message carrying both is chunked
by the time the connector sees it. The body itself goes to the application a
chunk at a time as it arrives, so an upload is never held here whatever its
size, and the connector will not take more of one than the application is
reading. A client that then sends less than it declared is answered 400, and
the application is left with an unfinished request rather than a short one
presented as complete.
What this lane does not do: a link the application writes inside a page (HTML,
CSS, a script) is not rewritten. Only the two response headers above are, and
finding every link a page or a script might construct would mean reading bodies
this lane does not read. Identity is substituted toward the application only
where the backend's identity says to, see below; the default is nothing, and
the caller's own credentials are what the application sees.
Forwarding caller identity to the application¶
An application reached through this lane is an ordinary client on its own
domain, which by default means it arrives anonymous: the caller's own
Authorization, if any, travels unchanged, and an application that wants to
know who is asking runs a login of its own. identity = "signed" on the backend
ends that: the connector puts a statement of who the gateway admitted into the
request, and the caller's own credential stops travelling.
The assertion is a compact JWS (EdDSA over Ed25519), in the Gdsgate-Identity
request header, which is this gateway's own name for its own statement rather
than one of the X-<prefix>-Jwt-Assertion spellings other gateways use, because an
application configured to trust one of those is configured to trust that
gateway. It carries iss (the constant gdsgate), sub (the access
certificate's subject), groups, owner (whom a delegated program or workload
acts for, absent for a person acting for themselves), iat, and aud, the
catalogued resource id, so an assertion minted for one application is not a
valid assertion at any other one behind the same gateway. aud comes from the
certificate's own scope, never from the name the connector asks about in the
request that wants it: a connector that could put its own choice of resource
into aud could ask about one resource while holding a certificate for another
and receive an assertion good for the first. exp is one minute out from iat;
see below.
Authority signs with a fifth CA generation, CaKind::JwtSigning, rather than the
key that signs access certificates, even though the two statements are otherwise
close to identical: the same subject, the same groups, the same scope, the same
Ed25519 signer. What differs is the audience. A key that signs access
certificates is verified only by software this cluster runs, over channels this
cluster controls; this key's public half is handed out in a document pulled by
whatever an operator put behind the web lane, and a name that means "this
verifies who a person is" should not also admit an SSH session. Rotate it with
gdsgate authority rotate-jwt-ca, manual only, because nothing here knows how
long an application behind the web lane caches the published key set; an operator
does, and times the rotation's propagation_secs accordingly. See
[ca_rotation].
The Proxy publishes the key set, at GET /.well-known/gdsgate/jwks.json on its
own browser origin ([proxy].web_addr). Not Authority, which the protected zone
is specifically not supposed to be able to reach, and not the connector, which
would need a listener and a serving certificate of its own and would turn a
key-set fetch over an unauthenticated connection into a key an attacker on that
path can replace. The Proxy is the one component both a browser and an
application behind the connector already have a route to, and the only one
presenting a certificate a stranger's HTTP client verifies without being
configured to. The document renders every currently-trusted JwtSigning
generation rather than only the one signing: the candidate generation while the
previous one is still signing, and the previous one until the last thing it
signed has expired, so an application that cached the document before a rotation
goes on verifying across it. Cache-Control: public, max-age=300 is the one
figure that bounds both how long the Proxy holds its own copy and how long an
application may hold its.
There is no RPC of its own per request. AuthorizeHttpRequest already runs on
every uncached request, already verifies the certificate, and already knows the
subject, the groups and the scope, so the answer carries two more fields. The
connector files the assertion in the permit cache beside the permit itself:
signed exactly when the decision is made, reused exactly as long as the decision
is, and expiring at the lesser of the operator's decision_cache_ttl_secs and
what is left of the certificate's own not_after. There is no second cache with
a schedule of its own, so an assertion cannot outlive the permit that justified
it, and a permit cannot hand an application a token the gateway itself no longer
believes. exp follows from the same number: a minute is the lane's five-second
default reuse window with margin either side, long enough that a permit is never
discarded for the assertion's sake and short enough that a statement about who
somebody is does not stay true after the gateway has stopped believing it.
When no assertion can be made, the request is refused rather than forwarded
anonymous. A backend with identity = "signed" and no per-request gate to mint
one, an older Authority or none configured at all, answers 502: the caller's
own credential has already been dropped by the time this is known, so what would
reach the application is neither the caller nor anybody the gateway named.
identity = "headers" states the same thing without a signature,
X-Remote-User and X-Remote-Groups, comma-separated, as the certificate
signed them, for software that reads a trusted header and cannot verify a JWS.
Its whole security is that the application is unreachable except through this
gateway, a fact about the network that only the operator knows, which is why
turning it on costs a second field of its own,
identity_headers_trust_the_network, worded as the claim being made rather than
as a switch: identity = "headers" without it, and it set without
identity = "headers", both refuse to start.
A client never gets to say any of this itself. Whichever shape is configured,
the caller's own Authorization is dropped unconditionally, and not only when a
statement was actually built, because an application handed both the gateway's
word and the caller's own would take whichever it prefers. Every inbound
Gdsgate-Identity and the whole x-remote- header family are stripped on the
way in, in every mode including none: those headers are this gateway's
vocabulary, so a client that sends one is not passing information along.
Recording the exchange¶
The audit chain is told that a request happened: http.request carries its
method, route, status and size. This is where what happened goes: the headers,
the bodies, and a channel's frames, streamed into the session's recording
alongside an SSH cast or a model exchange, sealed with a SHA-256 the session's
close row carries. record_request and record_response, the same two keys and
the same four levels kind = "llm" uses, set independently and defaulting to
digest/digest, choose how much, per direction, on both web and
web-egress:
| Level | What lands in the recording |
|---|---|
off |
Nothing for that direction. |
digest (default) |
Sizes and a SHA-256 fingerprint of the body: enough to prove what crossed the wire without storing it. |
redacted |
Bodies through the existing secret redactor. |
full |
Bodies verbatim, written as they stream. |
The format tag is gdsgate.http.exchange, the web lane's own, sharing its shape
(levels, the raw-byte fingerprint, the hand-framed JSON lines, the redactor a
redacted body goes through) with the model lane's gdsgate.llm.exchange but
not its fields: a method, a route and a status where the model lane carries a
model name and a token count. An artifact written by an older connector reads
exactly as it read.
A request that carries a body is written as three kinds of line, and one that
carries none as a single line. The body streams to the far end rather than being
held whole, so its size and its fingerprint do not exist when the request is
written down: a head says what was asked for, the chunks follow at full, and
an end carries bytes and sha256, the same shape the answer's side always
had. end also says cut_off where the body did not finish, so a PUT the
application abandoned, a client that sent less than it declared and a
max_request_bytes that stopped one are never mistaken for a completed upload.
A GET, a HEAD and a WebSocket handshake stay one line, where those two
fields are known before anything is sent. This is version 2 of the format;
version 1 carried every request on one line, and recordings show renders
both.
A credential header is never in the recording, at any level, full included.
authorization, proxy-authorization, cookie and set-cookie are written as
a name, a size and a fingerprint of the value, never the value itself. full
says the bodies are verbatim, and a session cookie is not a body, it is the
caller's ambient authority. Every other header value goes through the redactor
whatever the level, because a header is metadata and nothing is lost by masking
it.
A body this gateway cannot read is a body it cannot record, so turning a body
level on changes what is negotiated upstream: accept-encoding becomes
identity, and a WebSocket's permessage-deflate extension stops being
offered, exactly where a level writes bodies rather than merely where a
recording exists. At digest a fingerprint over compressed bytes is exactly as
true as one over plain bytes, since a fingerprint only ever proves which bytes
crossed the wire, so a session recording no bodies pays nothing here and keeps
the bandwidth it had.
A channel's frames are part of the exchange. Nothing further is decided after a
101, and a frame has a direction, an opcode, a length and a payload, so those
four facts are written one JSON line per frame, read off the bytes after they
are copied so nothing here can delay or drop one. The fingerprint is over the
unmasked payload: a client's frames are masked with a random key per frame, and
hashing the mask would hash a random number, making two identical messages
fingerprint differently a second apart.
Both halves of the lane record, and the way out is where it matters most. An
inspecting way out (inspect = true, see
Reading what a way out carries) is the one
place this gateway can say what a delegated program sent outside the zone. A
CONNECT nobody read leaves only its opening: name, port, 200, and no byte of
what went through it, because inspect = false means exactly that nothing here
can be recorded either. A refusal is written on either half, naming the host a
program tried and did not reach.
gdsgate authority recordings show <session_uid> renders both exchange formats
for reading, showing what was asked, what came back, and what was withheld, and
hands back the bytes of everything else, an asciicast included, unchanged;
--raw returns bytes always. See
CLI → gdsgate authority recordings show.
Running the MCP server as a child process¶
command changes what runs on the connector's node.
An addr backend reaches code running somewhere else, as somebody else. A
command backend runs the named program on this node, as the connector's own
user: the user that holds this node's Kubernetes service-account token, the model
lane's credential_file, the connector's own key and certificate, and the
configuration pointing at all of them.
The child does not inherit the connector's environment: it is cleared and rebuilt
from PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ, USER plus whatever
command_env names. That keeps the connector's other backends' secrets out of
the child's environment, and does nothing about the files the child can open.
What confines it is the [connector.backends.cage] table
(The cage). Declared, the child runs behind a deny-by-default
filesystem in a PID namespace of its own, with the network, the home directory,
the address families and the resource ceilings set by the switches there, and
both of a backend's programs are inside it, the server here and the
decider_command below. Not declared, there is no cage: the child runs with the
connector's own filesystem, and the connector's startup line says so on every
start.
Nothing requires the connector to start the server at all. Run it yourself under
an account of its own, or on a different host, and point addr or url at it.
The gateway gates a listening server, a remote one and a child process
identically, so this is a choice about where the code runs and under whose
account rather than about how much of it is controlled.
The program and its arguments come from the connector's configuration, which is
the connector operator's. Whether the resource pins its tool descriptors comes
from the catalog row, which is the Authority operator's. These can be two
different people, and descriptor pinning is not a defence against the first of
them: it catches a server that revises a descriptor after it was approved, not an
operator who points command at another program.
Two details of the form itself:
- The program is named by absolute path. A bare name would be resolved through
the
PATHthe connector inherited from whatever started it, so which program serves the resource would be decided by a variable rather than by the file. A relative or bare name is refused at startup. commandis a list rather than a command line. Nothing is handed to a shell, and an argument holding a space needs no quoting.
When a session ends, the server ends with it: its stdin is closed first, which is
the shutdown its own transport specifies, then the process group it leads is
signalled, so a helper the server started goes too. Two cases are beyond a
process group, and a cage is what reaches them: a descendant that calls setsid
leaves the group and survives, and a connector killed with SIGKILL runs no
shutdown at all. Under a cage the server is in a PID namespace whose first
process is the connector's, so leaving the group leads nowhere, and PDEATHSIG
is set on every link of that chain, a promise the cage measures and refuses to
run without, so a connector that goes takes the tree with it whether or not it
got to run anything.
The server's stderr is drained continuously, since an unread pipe fills and
wedges the server, and logged at debug level only, truncated per line and
capped per session. Under the default log level none of it is written anywhere.
The cage¶
[connector.backends.cage] confines the programs a backend runs on this node.
One table covers both programs: the command server above and the
decider_command below. They are one surface, a program out of this file running
here as this connector's user, and a setting that caged one of them would leave
the other open while reading as though it were shut.
Absent, there is no cage, which is what those two have always had.
The --sandbox basic|strict profiles of gdsgate delegate do not transfer: each
of them promises an empty network namespace, and an MCP server usually wants the
network on purpose, since reaching a wiki, an issue tracker or a provider's API
is what it exists for. A level that closed the network would break most servers
on the day it was switched on. The shape is therefore the one a unit file uses:
coarse switches next to the command, explicit paths for the rest, with the names
spelled as systemd.exec(5)'s are, for TOML.
The filesystem is an allow-list and there is no switch for it. A cage whose
filesystem is not one is not a cage: the threat is a tool server reading this
node's Kubernetes token, the model lane's credential file or the connector's own
key and forwarding it, and what a tool server forwards is not something this
gateway sees. A read-only root addresses none of that. What a caged program sees
is the system runtime, the few /etc files a process needs to start, the
character devices, its own installation prefix, its private $HOME and
$TMPDIR, and whatever read_only_paths and read_write_paths name. /proc is
not on that list, which is what closes /proc/<pid>/environ of every other
process of the same user.
| Setting | Type | Default | Applies to | What it does |
|---|---|---|---|---|
private_network |
bool | false |
MCP command or decider program | An empty network namespace: no interfaces, no route, no DNS. The default is false because most MCP servers want the network by design. There are two values and no third: an allow-list of destinations needs a proxy of its own or control of the program's name resolution, and neither is this setting. |
protect_home |
bool | true |
MCP command or decider program | $HOME is a private directory of this backend's, and the connector user's real home is not in the cage at all. false puts the real home back, read and write, for a server that keeps its state there. There is deliberately no read-only value: a read-only home stops the program changing the operator's files and does nothing about it reading their keys. |
private_tmp |
bool | true |
MCP command or decider program | $TMPDIR is that same private directory and /tmp is not in the cage. false allows /tmp and leaves $TMPDIR alone. |
read_only_paths |
list of strings | [] |
MCP command or decider program | Extra paths the program may read and execute, with everything beneath them. A path this host does not have is dropped rather than failing the start. |
read_write_paths |
list of strings | [] |
MCP command or decider program | The same, writable. This is where a server's own data goes, and where anything that has to outlive the node's temporary directory belongs. |
restrict_address_families |
list of strings | [] |
MCP command or decider program | The families the program may still open a socket in: AF_INET, AF_INET6, AF_NETLINK, AF_UNIX, and so on. Empty means no restriction; a name this build does not know is refused at startup. |
memory_max |
optional string | unset | MCP command or decider program | memory.max for the run's own cgroup: a byte count with an optional K, M or G. Holds the whole tree rather than one process of it. |
tasks_max |
optional integer | unset | MCP command or decider program | pids.max for the same cgroup. |
cpu_quota |
optional string | unset | MCP command or decider program | cpu.max as a percentage of one core: "100%" is one, "400%" is four. |
report_denied_paths |
bool | false |
MCP command or decider program | Name every path the program opens that this cage does not allow. See below. |
allow_degraded |
bool | false |
MCP command or decider program | Run even when this host cannot deliver something the table asks for. |
restrict_address_families is the only thing that closes a unix-domain socket
reached by path. Landlock through ABI 4 does not mediate connecting to one, and
such a socket belongs to no network namespace, so neither the allow-list nor
private_network touches it. Measured from a fully enforced cage whose
allow-list held neither directory: connections to a container runtime's socket
and to the session bus both went through, and an account in the runtime's group
is root on this host. ["AF_INET", "AF_INET6", "AF_NETLINK"] leaves a program
exactly what an empty network namespace already confines. An anonymous pair
(socketpair) is untouched whatever is set, the same exception
RestrictAddressFamilies= makes: both of its ends belong to the caller, neither
is reachable from a path, and language runtimes use it constantly.
report_denied_paths is how to find out what a server needs. The filesystem is
an allow-list, and nobody knows what somebody else's MCP server reads; the list
this cage ships was established by running real toolchains and watching what
broke. With this on, every open the program makes is put to the connector,
which names in a warning the ones outside the cage:
WARN a caged program asked for a path this cage does not allow; add it to
read_only_paths or read_write_paths if the server needs it
program=/usr/local/bin/mcp-filesystem path=/srv/docs/index.db
Turn it on, run the server, read the warnings, add the paths, turn it off. It
costs a round trip to the connector on every open, so it is a diagnostic rather
than a posture. It decides nothing: the boundary is Landlock's either way, and
this only watches, so a path it names that Landlock would have allowed is a
warning that should not have been printed, and one it misses is a warning that
was not. Neither changes what the program may do.
A promise that did not arrive stops the program. What the cage managed is
measured inside the program's own process, after the namespaces and the ruleset
have returned and before exec. If something the table asked for is not there,
the program is killed and the session fails, or, for the decider, the
consultation counts as unanswered, which under decider_fail_closed refuses the
call. allow_degraded = true is the operator saying they know: the program runs
with whatever was achieved, and the shortfall is named in the log and in the
session's own record.
That record is the session's session.close, beside the transport:
It carries what was measured rather than what was configured. A row built from the settings would say the same thing about a run that got everything and a run that got nothing.
The isolation strength is the kernel's. None of it contains a kernel exploit;
that threat model requires a virtual machine. A cage is also not the only option
for a third-party program on this node: a server reached over addr or url is
not started here at all.
The cage is built out of Linux kernel facilities (Landlock, namespaces, seccomp, cgroup v2). On any other platform a backend that declares one refuses to serve.
A remote MCP server over HTTP¶
url is the third way of naming one server, and the one the protocol defines for
a server that is not on this machine. Every message of a session is its own HTTP
POST to that URL; the answer comes back either as a single JSON object or as an
event stream carrying progress notifications and then the response. Everything
the gateway decides is unchanged: the same relay, the same Cedar decisions, the
same audit rows, the same descriptor pinning.
addr is not a substitute. That form speaks the newline-delimited framing over a
socket, which the specification permits for a custom transport and which this
lane has always spoken, and which is not what a remote MCP server serves.
https is verified against the platform roots plus whatever ca_path names, so
an operator's own server under a private CA is a file path rather than a
verification that has to be turned off. If the server wants a credential, it is
read from credential_file and shaped by credential_header /
credential_format, the same three keys and the same checks the model lane uses.
There is no caller credential on this leg to substitute: the connector is the
client, and the request it POSTs is one it built.
Redirects are not followed and not passed on. A 3xx is answered to the caller
as a refusal, because following a Location would mean presenting this
resource's credential at an address chosen by whoever can answer for the
configured one.
A tool has to have been listed before it can be called. The listing is where a
server states which of a tool's arguments it wants mirrored into Mcp-Param-*
headers, and the revision requires a server to refuse a request whose mirrored
headers do not match its body. A call the connector cannot make conforming is
refused here, with a message saying so, rather than sent short. Every MCP client
lists before it calls; one written to call a tool it never listed sees this. What
a server said about its tools is remembered per backend, so a client that lists
on one connection and calls on another is unaffected.
This form changes nothing about the connector's node: a url backend is code
running somewhere else, as somebody else. It is the alternative for an operator
who does not want a server process on the connector's node.
The external decider¶
Some of what an organisation wants decided about a tool call is not something gdsgate can decide: whether a prompt carries an injection, whether an argument names a path this team is allowed to touch, whether a request looks like the beginning of an exfiltration. Those are judgements about content and about rules this gateway does not hold.
decider_url / decider_command is the interface for whoever does hold them.
Before anything on a data path leaves for the server, the decider is asked,
synchronously, and the call waits for the answer.
The contract is allow|block:
There is no way for a decider to hand back a changed call. "It can only narrow
the request" is not something this gateway can check, because it does not know
what a tool's arguments mean: turning delete(path="/tmp/x") into
delete(path="/") is an edit of one field too.
It can only add a refusal, and it is asked only about calls that were going to be carried. The allow-list, the Cedar decision and descriptor pinning all run first; a call any of them refused is never put to the decider. A decider therefore cannot widen access, and installing one cannot open anything.
What it is shown is what the audit chain records about the same call: the caller and its owner, the resource, the session, which of the three paths, what was named on it, and the arguments after the same secret redaction and the same size cap the chain applies. Not the raw line, not the JSON-RPC id, not the caller's certificate.
{
"gdsgate": "mcp.decide/1",
"session_uid": "<session-uid>",
"resource": "ai-tools",
"actor": "Agent::\"builder\"",
"owner": "User::\"alice\"",
"method": "tools/call",
"name": "read_file",
"arguments": "{\"path\":\"/srv/docs/changelog\"}",
"arguments_truncated": false
}
Every shape of not answering is the same thing, a call nobody decided, and
decider_fail_closed (default true) is the single switch over all of them:
| Shape | Recorded as |
|---|---|
| the decider could not be reached, or the program would not start | unreachable |
| the deadline passed | timed_out |
a decider_command exited non-zero, or was killed |
exit_status |
a decider_url answered outside 2xx, a redirect included, since they are not followed |
http_status |
| nothing came back | no_answer |
| something came back and it was not the contract | malformed |
| the answer ran past the size cap | too_large |
None of them reads as permission. A decider_command that exits non-zero is
judged on the status before its output: a program that failed has not answered,
whatever it printed on its way out.
Under the default posture, a decider that is down stops every data path on that
backend. This is the same shape of trade refuse_unfiltered_tool_listing makes,
and the only thing that softens it is running the decider redundantly rather than
a setting here. decider_fail_closed = false inverts it: the call is carried,
and mcp.decider_hook records that nobody decided it, so the gap is countable
rather than invisible.
decider_command starts a program on every request. Measured on the machine this
was built on: about 1.9 ms per consultation against about 0.35 ms for a POST to
a decider on loopback, five times the latency, and a fork/exec per call
rather than a connection. For anything with real traffic, put the decider behind
decider_url. The subprocess form is for the decider that is a short script.
A decider_command also runs on this node as the connector's own user, which is
the surface
running the MCP server as a child process
describes, with the same answer: the cage a backend declares
confines this program too, from the same table, and a backend that declares none
runs it with the connector's own filesystem. One consequence is worth knowing in
advance: a cage whose promises this host cannot keep makes the consultation an
unanswered one, and under decider_fail_closed that refuses the call.
Every consultation writes one mcp.decider_hook row, whichever way it went, next
to the mcp.tool_call / mcp.resource_read / mcp.prompt_get row for the same
call. That is how a reader tells the three cases apart:
| What happened | On the chain |
|---|---|
| policy refused the call | the call's row with Failure, and no mcp.decider_hook row, since the decider was not asked |
| the decider refused it | the call's row with Failure, and mcp.decider_hook with verdict=block and the decider's own reason |
| the decider could not answer | mcp.decider_hook with verdict=unanswered, the failure named, and refused saying whether it cost the call |
The decider's reason goes to the chain and not to the caller: it is the
operator's program's words about the operator's own rules, and a refused caller
is told only that the gateway's external decider refused it, exactly as a Cedar
denial does not hand back the policy.
Pinning tool descriptors¶
A tool is approved on the strength of its descriptor: its name, description,
input schema and annotations. The description is the text the model reads and
acts on, and a server can serve a benign descriptor while the tool is being
approved and a different one afterwards without changing anything the transport
can see. Filtering tools/list by name does not touch it.
pin_tool_descriptors = true turns that from invisible into recorded. It is
declared on the resource, in Authority's own catalog:
# Authority's configuration: the catalog
[[discovery.resources]]
id = "tools-mcp"
kind = "mcp"
port = 8765
pin_tool_descriptors = true
# The connector's configuration: no pinning switch of its own
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
enforce_tool_policy = true # carries the round-trips the pin travels on
The same flag on a running cluster:
$ gdsgate authority set-resource tools-mcp --kind mcp --port 8765 --pin-tool-descriptors
created tools-mcp mcp project=default env=dev hostname=tools-mcp port=8765 pin_tool_descriptors=on
The connector is the party the guarantee constrains, which is why the flag lives
on the resource. While it was a backend option, a connector whose configuration
had been edited, or whose binary had, stopped asking for pinning, and the
guarantee went off without a word. The catalog row is written by the operator and
by nobody else: Authority's own [discovery], or Authority.UpsertResource
behind the control-plane certificate and Cedar manageCatalog, which an enrolled
connector is refused on. pin_tool_descriptors under [[connector.backends]] is
refused at startup, naming where the flag lives now.
What happens:
- every
tools/listreports each descriptor's digest to Authority. The first descriptor seen for a tool becomes its baseline and is recorded asmcp.descriptor_pinned; - a later descriptor that differs is recorded as
mcp.descriptor_changed, and the listing is not refused; - a
tools/callon a tool whose descriptor has changed is refused, with a remedy: the caller can rungdsgate request-accessfor that tool and an approver clears it withgdsgate approve. Approving that request is also asserting that the descriptor now being served is acceptable; the promotion is recorded asmcp.descriptor_repinned; - a server that goes back to the descriptor it was pinned as clears the refusal by itself, with no ceremony;
- the baseline lives in the database, sealed with the operator's inventory key
(
[audit].inventory_key_path) so a database writer cannot re-point it. Without that key configured, a session with pinning on refuses every call; - every one of these records goes on the chain under the
ResourceIntegrityclass, sogdsgate authority audit list --class resource-integrityis everything pinning has observed on this cluster. Records written before that class existed are underAuthorization;--code MCP-0301and its neighbours span both. See Operations → What lands in audit.
Four limits:
- Trust on first use. The first descriptor seen is trusted. This detects a server that changed after it was trusted, and not one that was hostile from the start.
- A server that revises its tools raises a refusal every time. That is why this is per resource and off by default: point it at servers whose tool surface is meant to be stable.
- The observation is still the connector's. Only the connector sees a descriptor,
so only it can report one. Moving the switch out of its hands does not move the
seeing: a connector that reports nothing on a pinned resource is one under
which pinning is not happening. Authority records
mcp.descriptors_unreportedon every such listing rather than letting the silence pass for compliance, and it is the row to watch after turning the guarantee on. instructionsare recorded rather than enforced. A change to a server's handshake text lands asmcp.descriptor_changedagainst the server and refuses nothing. It is not a tool: there is no per-tool approval to clear it with, and refusing on it would let one edit to a server's blurb deny every tool on it.
notifications/tools/list_changed, the server announcing that its tools have
changed, is recorded as mcp.tools_changed and relayed. It names no tool and
proves no change; what it announces is caught on the re-listing that follows.
Behavior when Authority is unreachable¶
With enforce_tool_policy on, a tools/list result is filtered per caller by the
Cedar viewTools decision. When that decision cannot be obtained, because
Authority is unreachable or answering with an error, the gateway has a listing it
was not able to filter.
By default it forwards the listing as served and records
mcp.tool_listing_undecided (outcome = Failure) with the cause (reason:
unreachable for an outage, unconfigured for a session with nothing to decide
with), what was done (posture: refused or forwarded_unfiltered) and how many
tool names were at stake (tools). This grants nothing: the access boundary is
the call, and tools/call fails closed on the same unreachable Authority, so an
unfiltered listing shows names and passes no call.
refuse_unfiltered_tool_listing = true makes the other trade: the caller is
answered with a JSON-RPC error instead of the listing, the same error shape every
gdsgate refusal on this lane carries, and no name of a tool it may not see leaves
the gateway.
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
enforce_tool_policy = true # required: there is no filter without it
refuse_unfiltered_tool_listing = true
Which posture to choose depends on the servers behind the backend:
- refusing hides the names and ends the session, since a client that cannot list tools cannot operate, so an Authority outage becomes a full outage for every MCP caller;
- forwarding keeps the session working in a degraded form and discloses the names of tools this caller may not see, without their descriptors and without access to them.
The refusal is an error and not an empty list, because an empty tools array
states that the server has no tools, which a client would act on.
Two limits:
- the setting governs the decision rather than the parse. A listing whose tool names the gateway cannot read is forwarded unchanged, as it already was with Authority up, because there was nothing there to filter. Such a listing is no bypass: the field the gateway reads is the field the client reads;
- a session with
enforce_tool_policyon but no Authority client or access certificate is a misconfiguration the connector refuses to start with. If one is reached anyway it counts as undecided too, recorded withreason = unconfiguredso it is not mistaken for an outage.
The model lane¶
A kind = "llm" backend is a model service in the catalogue, reached the same
way every other resource is: delegated as llm:call@<resource>, authorized by
Cedar, audited, revoked, listed by gdsgate ls. A model is a resource, and there
is no second mechanism anywhere in this.
Every call on the lane is decided before a byte leaves for the provider. An llm
backend that cannot reach Authority refuses to start, since a model lane that
cannot decide would forward everything.
Two credential modes, which are not the same promise:
| Mode | What the provider sees | What the connector holds |
|---|---|---|
Substitution, credential_file set |
The organisation's key, and only the headers allowed_request_headers names on top of content negotiation |
The connector reads and holds the organisation credential from credential_file; the provider sees it. |
Pass-through, credential_file unset |
The caller's own credential, travelling as it always did | The connector contributes no provider credential; the caller's credential traverses to the provider. |
Substitution forwards caller headers by allow-list rather than by deny-list: its
promise is that no caller-chosen credential reaches the provider, and a deny-list
cannot keep that promise, since the first spelling missed from it is a connector
authenticating as itself. The visible cost is that a service wanting its own
header (anthropic-version) is refused by the provider, rather than by the
gateway, until that header is named. The same halved promise applies to the URL:
a query string is refused except for the parameters allowed_query_params names.
credential_file is read once, at startup, so rotating the key needs a restart of
the connector, the same as token_path on a Kubernetes backend.
Token accounting is mandatory. Input and output counters go into the audit chain
on every call. There is no option that turns them off and nowhere in this file to
write one. The row also says tokens=absent when the provider sent no counters
and tokens=unscannable when they could not be read, so "no counters" is never
rendered as zero.
Recording is separate, and defaults to storing nothing. Prompts and answers are
content, so they go where session content goes: the recording store, streamed,
with a SHA-256 sealed into the chain and a recording://<uid> reference, read
back under the same viewRecordings action as an SSH cast.
| Level | What lands in the recording |
|---|---|
off |
Nothing; the counters still reach the chain |
digest (default) |
Sizes, the model's name, fingerprints of the bodies |
redacted |
Bodies through the existing secret redactor |
full |
Bodies whole, written chunk by chunk as they stream |
record_request and record_response are set independently and any composition
is valid: off / full keeps what a model sent without keeping what it was
sent, and redacted / off keeps what left the perimeter with secrets stripped.
digest is the default because prompt bodies are the most sensitive content this
gateway carries. What a resource writes is printed by the connector at startup,
as a warning where bodies are stored, and reported per resource by
gdsgate doctor.
Two limits of the lane, both fail-closed: it carries POST only, and a call
whose body is not JSON with a top-level model is refused. The decision on this
lane is a decision about a model, and the model is in the body. The visible
consequence is that a discovery call such as GET /v1/models is answered 405,
so an SDK that begins by listing models does not work through it.
[connector.backends.discovery]¶
A live-discovery probe on a backend. The connector periodically enumerates the
backend's sub-resources under a read-only account. For a PostgreSQL host the
discovered databases show up in gdsgate ls as connectable <host>.<db>
resources; for an MCP server the discovered tools feed the per-identity
tools/list filter (viewTools) and are not added to gdsgate ls. Either way
the inventory is observational: it populates a listing and never grants access.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
mode |
string | unset, required | Postgres or MCP backend | What to enumerate: list_databases (a Postgres host's databases, surfaced in gdsgate ls) or list_tools (an MCP server's tools, used to filter tools/list, not surfaced in gdsgate ls). |
connection |
string | "" |
Postgres backend | Read-only connection string for the probe account (user and database), for example postgres://disc_ro@host/postgres. Postgres only; omit for list_tools. |
password_file |
string | "" |
Postgres backend | Path to a file holding the probe account's password. Postgres only. |
interval_secs |
u64 | 300 |
Postgres or MCP backend | How often to probe. |
allowlist |
array of patterns | [] |
Postgres or MCP backend | Globs applied before publishing. Empty allows all, subject to denylist. |
denylist |
array of patterns | [] |
Postgres or MCP backend | Globs applied after allowlist. |
freshness_secs |
optional u64 | unset; derived 4 × interval_secs |
Postgres or MCP backend | Validity window of a published snapshot. Past it, the entries drop out of listings until the next probe refreshes them. |
password_file accepts the secret-reference notation
in place of a bare path; it is read once, at startup, and the probe reuses the
resolved password on every subsequent poll.
For Postgres, use a separate, least-privilege account, with only SELECT on
pg_database, rather than the session_role service account. Set
[audit].inventory_key_path to seal each published snapshot with an
integrity receipt.
Forward gating¶
For ssh -L (allow_local_forward) and ssh -R (allow_remote_forward), each
list entry is a pattern:
| Pattern | Meaning |
|---|---|
host:port |
exact host (case-insensitive), exact port |
host:lo-hi |
exact host, inclusive port range |
host:* |
exact host, any port |
*:port / *:lo-hi |
any host on this port or range |
CIDR:port / CIDR:lo-hi |
the request host must be a literal IP in the CIDR (IPv4 or IPv6) |
[ipv6]:port |
IPv6 host in square brackets, to avoid colon ambiguity |
Examples:
allow_local_forward = [
"redis.internal:6379",
"10.0.0.0/8:5432",
"[fd00::/64]:6379",
"metrics.internal:*",
"*:443",
]
allow_remote_forward = ["127.0.0.1:8000-9999"]
Defaults are conservative: with allow_local_forward = [], only loopback is
reachable through -L; with allow_remote_forward = [], -R is disabled. Both
lists are Layer 1, and the Cedar policy (sshForwardLocal / sshForwardRemote)
is Layer 2, per user and per session. Both must permit the forward.
ssh -A is gated the same way and has no patterns, because it names no
address. Its Layer 1 is the switch allow_agent_forward, false by default,
and its Layer 2 is the Cedar action sshForwardAgent:
What it opens is wider than a port: for as long as the session lives, anything
that reaches the forwarded socket authenticates with the client's keys wherever
those keys are trusted. That is the shell, whatever the shell starts, and root
on the Connector host. Refused by either layer, the Connector binds no socket, so no program
in the session sees SSH_AUTH_SOCK at all. Both outcomes are audited as
sshForwardAgent decisions naming the resource and the session.
Complete backend examples¶
Each block below is a complete TOML document for one connector backend. The
hostnames use the reserved .invalid suffix and the file paths are
deployment-relative placeholders, not credentials. After replacing them with
reachable endpoints and mounted secret files, run gdsgate doctor --config
<file> before serving traffic.
SSH, model A (connector-terminated)¶
[connector]
id = "edge-ssh-local"
[[connector.backends]]
resource = "jump-shell"
kind = "ssh"
allow_local_forward = ["127.0.0.1:8000-8100"]
allow_remote_forward = []
SSH, model B (jump host)¶
[connector]
id = "edge-ssh-jump"
[[connector.backends]]
resource = "jump-fleet"
kind = "ssh"
addr = "sshd.example.invalid:22"
login_user = "deploy"
# Replace this development-only escape hatch with a protected fingerprint file
# before serving real traffic.
allow_unpinned_upstream = true
PostgreSQL¶
[connector]
id = "edge-postgres"
[[connector.backends]]
resource = "orders-db"
kind = "postgres"
addr = "postgres.example.invalid:5432"
allowed_backend_hosts = ["postgres.example.invalid:5432"]
CockroachDB¶
A CockroachDB connector backend uses kind = "cockroach". Its catalog
resource uses the PostgreSQL wire-kind spelling kind = "postgres"; the two
declarations are intentionally different.
[connector]
id = "edge-cockroach"
[[connector.backends]]
resource = "orders-roach"
kind = "cockroach"
addr = "roach.example.invalid:26257"
allowed_backend_hosts = ["roach.example.invalid:26257"]
[[discovery.resources]]
id = "orders-roach"
kind = "postgres"
port = 26257
MySQL¶
[connector]
id = "edge-mysql"
[[connector.backends]]
resource = "legacy-db"
kind = "mysql"
addr = "mysql.example.invalid:3306"
allowed_backend_hosts = ["mysql.example.invalid:3306"]
Kubernetes¶
[connector]
id = "edge-kubernetes"
[[connector.backends]]
resource = "prod-cluster"
kind = "kubernetes"
api_url = "https://kubernetes.example.invalid:6443"
ca_path = "secrets/kubernetes-ca.pem"
token_path = "secrets/kubernetes-token"
allow_port_forward = false
TCP¶
[connector]
id = "edge-tcp"
[[connector.backends]]
resource = "metrics"
kind = "tcp"
addr = "metrics.example.invalid:9090"
allowed_backend_hosts = ["metrics.example.invalid:9090"]
MCP over a socket¶
[connector]
id = "edge-mcp"
[[connector.backends]]
resource = "tools"
kind = "mcp"
addr = "mcp.example.invalid:8765"
allowed_backend_hosts = ["mcp.example.invalid:8765"]
allowed_tools = ["search", "read"]
LLM¶
[connector]
id = "edge-llm"
[[connector.backends]]
resource = "model-service"
kind = "llm"
addr = "https://model.example.invalid/v1"
allowed_backend_hosts = ["model.example.invalid:443"]
credential_file = "secrets/model-api-key"
allowed_models = ["safe-model"]
Web application¶
[connector]
id = "edge-web"
[[connector.backends]]
resource = "portal"
kind = "web"
url = "https://portal.example.invalid"
allowed_backend_hosts = ["portal.example.invalid:443"]
Web egress¶
[connector]
id = "edge-web-egress"
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["api.example.invalid", "packages.example.invalid"]
inspect = false
# Only where the network really carries a range nothing allocated to the
# public internet; the default is to dial none of them.
# routed_ranges = ["fec0::/10"]
These examples exercise the connector shape only. A catalog entry still
belongs in Authority's [[discovery.resources]] inventory, and an enrolled
deployment must add the appropriate [endpoints] and [enroll] settings from
the family pages.