Skip to content

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.

[connector]
id             = "edge-1"
authority_transport = "fronted"

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:

  1. 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.
  2. 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.
  3. The Proxy's own next leader-only call finds the new leader, and its pool moves.
  4. 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 PUBLIC on one table, since PUBLIC reaches 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 DEFINER function 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.

[[connector.backends]]
resource = "redis-cache"
kind     = "tcp"
addr     = "127.0.0.1:32768"

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 PATH the 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.
  • command is 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:

"transport":"stdio","cage":"landlock=FullyEnforced,fs=enabled,netns=enabled"

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:

{"decision": "allow"}
{"decision": "block", "reason": "path outside the project root"}

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/list reports each descriptor's digest to Authority. The first descriptor seen for a tool becomes its baseline and is recorded as mcp.descriptor_pinned;
  • a later descriptor that differs is recorded as mcp.descriptor_changed, and the listing is not refused;
  • a tools/call on a tool whose descriptor has changed is refused, with a remedy: the caller can run gdsgate request-access for that tool and an approver clears it with gdsgate approve. Approving that request is also asserting that the descriptor now being served is acceptable; the promotion is recorded as mcp.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 ResourceIntegrity class, so gdsgate authority audit list --class resource-integrity is everything pinning has observed on this cluster. Records written before that class existed are under Authorization; --code MCP-0301 and 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_unreported on every such listing rather than letting the silence pass for compliance, and it is the row to watch after turning the guarantee on.
  • instructions are recorded rather than enforced. A change to a server's handshake text lands as mcp.descriptor_changed against 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_policy on 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 with reason = unconfigured so 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:

[[connector.backends]]
resource = "jump-host"
kind     = "ssh"
allow_agent_forward = true

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.