> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/openshell/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/openshell/_mcp/server.

# Gateway Configuration File

> Reference for the OpenShell gateway TOML configuration file (RFC 0003).

The OpenShell gateway reads its configuration from a TOML file when `--config` or `OPENSHELL_GATEWAY_CONFIG` is set. When neither is set, the gateway reads `$XDG_CONFIG_HOME/openshell/gateway.toml` if that file exists. If no config file exists, the gateway starts from built-in defaults. Gateway process flags and gateway `OPENSHELL_*` environment variables override the file. Compute driver settings live in the driver TOML tables. See [RFC 0003](https://github.com/NVIDIA/OpenShell/blob/main/rfc/0003-gateway-configuration/README.md) for the full schema.

## Source Precedence

```text
Gateway CLI flag  >  gateway OPENSHELL_* env var  >  TOML file  >  built-in default
```

`database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`.

`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`.

## Package-Managed Locations

Package-managed gateways use either built-in defaults or a package-seeded TOML file. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file.

| Package         | Gateway TOML location                                                                                                                                                      |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Homebrew        | `$XDG_CONFIG_HOME/openshell/gateway.toml` when it exists, otherwise the Homebrew prefix config such as `/opt/homebrew/var/openshell/gateway.toml`.                         |
| Debian/Ubuntu   | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml` for the systemd user service.                                                        |
| Fedora/RHEL RPM | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml`; the systemd user service seeds this file from the packaged template on first start. |
| Snap            | `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`.                                                                                            |

The Fedora/RHEL RPM template leaves `[openshell.gateway].bind_address` unset. The gateway therefore uses its built-in `127.0.0.1:17670` primary listener. The Podman driver negotiates separate, restricted listeners for sandbox callbacks, so the primary listener does not need a wildcard address. Set `bind_address` explicitly only when clients must reach the primary multiplexed API through another interface.

The Homebrew formula creates its prefix config without setting `bind_address`, so the gateway uses its built-in `127.0.0.1:17670` primary listener. Docker Desktop and Podman Machine reuse that listener for sandbox callbacks. A user config takes precedence.

Homebrew and RPM upgrades migrate only exact package-generated schema-v1 defaults. Homebrew recognizes both its empty v1 prefix config and the affected IPv6-loopback variant. RPM recognizes the v1 file seeded by its systemd user service. Package upgrades never rewrite an edited file; migrate an edited v1 file manually with the steps below.

## Layout

The file is rooted at `[openshell]`. Gateway-wide settings live under `[openshell.gateway]`. Each compute driver owns its own `[openshell.drivers.<name>]` table. Credential drivers own `[openshell.credential_drivers.<name>]` tables. Driver-specific values are never inherited from gateway scope.

```toml
[openshell]
version = 2

[openshell.gateway]
# ... gateway-wide settings ...

[openshell.gateway.tls]
# ... gateway listener TLS ...

[openshell.gateway.oidc]
# ... JWT bearer auth ...

[openshell.drivers.kubernetes]
# ... driver-specific settings ...

[openshell.credential_drivers.kubernetes-secrets]
# ... credential-driver-specific settings ...
```

The gateway selector is `compute_driver = "<driver>"`. It accepts one scalar driver name. Omit it to retain auto-detection.

## Migrate to schema version 2

Schema version 2 is an intentional breaking cutover. The gateway rejects files
that omit `[openshell] version`, declare version 1, or declare an unsupported
future version. To migrate an existing file:

1. Set `[openshell] version = 2`.
2. Replace `compute_drivers = ["<name>"]` with the scalar
   `compute_driver = "<name>"`. Replace `--drivers` and `OPENSHELL_DRIVERS`
   with `--compute-driver` and `OPENSHELL_COMPUTE_DRIVER`. Package upgrades
   temporarily accept one non-empty `OPENSHELL_DRIVERS` value as a deprecated
   environment-only alias when it agrees with any canonical selection.
   Comma-delimited, invalid, or conflicting legacy values fail startup. The
   removed `--driver` and `--drivers` flags remain unsupported.
3. Move every compute-driver option into `[openshell.drivers.<name>]`. Schema
   version 2 does not inherit driver defaults from `[openshell.gateway]`.
   Keep only `guest_tls_ca`, `guest_tls_cert`, and `guest_tls_key` at gateway
   scope. A TLS-enabled Docker, Podman, or VM gateway requires one complete
   guest bundle. Set all three paths unless the package-managed local TLS
   bundle supplies them. When TLS is disabled, omit all three. Kubernetes
   projects sandbox TLS through `client_tls_secret_name` instead.
4. Rename Docker `sandbox_namespace` to `sandbox_label`, Podman
   `sandbox_ssh_socket_path` to `ssh_socket_path`, and VM
   `openshell_endpoint` to `grpc_endpoint`.
5. Use canonical image pull policies: `always`, `if_not_present`, `never`, or
   Podman-only `newer`. Kubernetes-style capitalization and Podman's `missing`
   spelling are rejected.
6. Remove zero sentinels. Omit `gateway_jwt.ttl_secs` for a non-expiring token,
   omit Docker or Podman `sandbox_pids_limit` to use OpenShell's default limit
   of 2048, and omit Podman `health_check_interval_secs` to disable health
   checks. Explicit zero values are invalid.
7. Remove local Docker, Podman, or VM `grpc_endpoint` when the topology-derived
   callback is correct, or retain it as an explicit override. Kubernetes raw
   TOML requires an explicit endpoint; Helm derives one from the release's
   gateway Service. New VM root filesystems use an image-provided `sandbox`
   account when present and otherwise use UID/GID 1000. Existing persisted VM
   state retains its recorded or recoverable identity, including legacy 10001;
   the driver does not assign 10001 to an overlay without supporting state.

Every `[openshell.drivers.<name>]` entry must be a TOML table. The gateway
validates driver-specific fields when it selects and constructs that driver;
it does not deserialize unselected driver tables. Unknown or misplaced fields
in the selected table fail startup instead of being silently ignored.

## Full Example

A complete gateway configuration covering every section. Trim to the fields you need.

```toml
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[openshell]
version = 2

[openshell.gateway]
name                  = "production-us-west"
bind_address          = "0.0.0.0:8080"
health_bind_address   = "0.0.0.0:8081"
metrics_bind_address  = "0.0.0.0:9090"

log_level             = "info"

# When omitted, the gateway auto-detects Kubernetes, then Podman, then Docker.
# VM is never auto-detected and requires an explicit entry here.
compute_driver        = "kubernetes"

# Optional external provider credential storage backend. Omit this key to use
# the gateway's default encrypted database credential storage.
credential_drivers        = ["kubernetes-secrets"]

ssh_session_ttl_secs  = 3600

# Reject invalid policy generations securely by default. Set
# "retain_last_valid" only when availability takes priority.
policy_validation_failure_mode = "fail_closed"

# Subject Alternative Names baked into the gateway server certificate.
# Wildcard DNS SANs (e.g. "*.dev.openshell.localhost") also enable sandbox
# service URLs under that domain.
server_sans                  = ["openshell", "*.dev.openshell.localhost"]
# Allow plaintext HTTP routing for loopback sandbox service URLs.
enable_loopback_service_http = true

# Set true only for local plaintext gateways or trusted TLS termination.
disable_tls = false

# Guest TLS paths remain gateway settings. TLS-enabled Docker, Podman, and VM
# gateways require a complete bundle unless package-managed local TLS supplies
# it automatically. Omit all three when TLS is disabled. Kubernetes projects
# sandbox TLS from client_tls_secret_name instead. Driver tables must not repeat
# these fields.
guest_tls_ca            = "/etc/openshell/certs/ca.pem"
guest_tls_cert          = "/etc/openshell/certs/client.pem"
guest_tls_key           = "/etc/openshell/certs/client-key.pem"

# Optional gRPC rate limit. Both values must be positive to enable the limit.
# Set either value to 0, or omit both, to disable rate limiting.
grpc_rate_limit_requests       = 120
grpc_rate_limit_window_seconds = 60

# Optional exact provider-profile source composition. When omitted, the
# gateway uses builtin + user.
provider_profile_sources = [
  { type = "builtin" },
  { type = "user" },
]

# Operator-run supervisor middleware. The gRPC endpoint must be reachable from
# both the gateway and sandbox supervisors.
[[openshell.supervisor.middleware]]
name = "local-content-guard"
grpc_endpoint = "https://host.openshell.internal:50051"
tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem"
audience = "urn:openshell:middleware:local-content-guard"
max_payload_bytes = 262144
timeout = "500ms"

# Gateway listener TLS (distinct from the per-driver guest_tls_*).
# client_ca_path is optional; omit it for HTTPS-only listeners that do not
# verify client certificates.
[openshell.gateway.tls]
cert_path             = "/etc/openshell/certs/gateway.pem"
key_path              = "/etc/openshell/certs/gateway-key.pem"
client_ca_path        = "/etc/openshell/certs/client-ca.pem"
# Optional: SNI-based dual certificate for external (e.g. ACME) TLS.
# external_cert_path    = "/etc/openshell/certs/external.pem"
# external_key_path     = "/etc/openshell/certs/external-key.pem"
# external_server_names = ["gateway.example.com"]

[openshell.gateway.gateway_jwt]
signing_key_path = "/etc/openshell/jwt/signing.pem"
public_key_path  = "/etc/openshell/jwt/public.pem"
kid_path         = "/etc/openshell/jwt/kid"
gateway_id       = "openshell"
# Omit only for local single-player Docker, Podman, or VM gateways.
ttl_secs         = 3600

[openshell.gateway.auth]
allow_unauthenticated_users = false

[openshell.gateway.mtls_auth]
enabled = false

# OTLP export. Omit this table entirely to disable it.
[openshell.gateway.otlp]
endpoint     = "http://otel-collector.observability.svc:4317"
service_name = "openshell-gateway"

[openshell.gateway.oidc]
issuer        = "https://idp.example.com/realms/openshell"
audience      = "openshell-cli"
jwks_ttl_secs = 3600 # Must be greater than zero.
roles_claim   = "realm_access.roles"
admin_role    = "openshell-admin"
user_role     = "openshell-user"
scopes_claim  = ""

[[openshell.gateway.interceptors]]
name               = "quota"
grpc_endpoint      = "unix:///run/openshell/interceptors/quota.sock"
audience           = "urn:openshell:interceptor:quota"
order              = 10
failure_policy     = "fail_closed"
binding_policy     = "allowlist"
timeout            = "500ms"
max_response_bytes = 1048576
max_patches        = 32

[[openshell.gateway.interceptors.bindings]]
rpc            = "openshell.v1.OpenShell/CreateSandbox"
phases         = ["modify_operation", "validate"]
failure_policy = "fail_closed"

[[openshell.gateway.interceptors.bindings]]
rpc    = "openshell.v1.OpenShell/UpdateConfig"
phases = ["validate"]

[openshell.drivers.kubernetes]
namespace              = "openshell"
# Required in raw TOML; Helm derives this from the gateway Service.
grpc_endpoint          = "https://openshell-gateway.openshell.svc:8080"
default_image          = "ghcr.io/nvidia/openshell/sandbox:latest"
# Defaults to the gateway version; override to pin a specific build.
# supervisor_image     = "ghcr.io/nvidia/openshell/supervisor:<version>"
client_tls_secret_name = "openshell-client-tls"
service_account_name   = "openshell-sandbox"
host_gateway_ip        = "10.0.0.1"
enable_user_namespaces = false
sa_token_ttl_secs      = 3600

[openshell.credential_drivers.kubernetes-secrets]
namespace = "openshell"
allow_reference_namespace = false
```

Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to map a verified client certificate to a CLI user identity. This application-layer identity switch does not control the TLS handshake. When `client_ca_path` is set without OIDC, the listener requires a valid client certificate. When OIDC is configured, bearer-only clients may connect; the listener still validates any client certificate they present against the configured CA. Kubernetes deployments must leave `mtls_auth.enabled` unset and use OIDC or a trusted access proxy; the Helm chart does not render this table.

The client-certificate handshake policy is derived and has no `require_client_auth` TOML field. This preserves bearer-only OIDC clients and prevents a file setting from silently weakening CA-only gateways.

`[openshell.gateway.tls]` supports optional SNI-based dual-certificate mode for deployments that need separate internal and external server certificates. Set `external_cert_path` and `external_key_path` to point at the external (e.g. ACME/publicly-trusted) certificate and key. List the hostnames that should be served with the external certificate in `external_server_names`. Connections whose TLS SNI hostname matches one of those names receive the external certificate; all other connections (including those with no SNI) receive the primary internal certificate from `cert_path`/`key_path`. Both fields must be set together — providing only one is a configuration error. On Kubernetes with the Helm chart, the external certificate is managed automatically when `certManager.serverIssuerRef.name` is set; the chart populates these fields from the cert-manager-issued external server certificate.

`[openshell.gateway] policy_validation_failure_mode` controls what sandbox supervisors do when a complete candidate policy fails runtime validation. The default, `fail_closed`, deactivates the previous network policy, closes relays pinned to it, and denies new egress until a valid generation loads. `retain_last_valid` leaves the previous valid generation active. Both modes reject the candidate atomically; startup always fails closed when no previous valid generation exists. Gateway mutation paths that can preflight a known effective scope reject invalid candidates before persistence and leave the active policy unchanged regardless of this setting. Changing the value requires restarting the gateway so it can reload `gateway.toml` and distribute the new posture to sandbox supervisors.

`[openshell.gateway.gateway_jwt] ttl_secs` controls gateway-minted sandbox JWT lifetime. Omit it for a non-expiring token: the token `exp` claim and `expires_at_ms` response field become `0`. Use this only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field.

`[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways.

## OTLP Export

`[openshell.gateway.otlp]` enables OpenTelemetry export over OTLP/gRPC. Omit the table to disable export; there is no separate `enabled` flag.

The gateway already uses the Rust `tracing` framework for structured logs sent to stdout and the sandbox log stream. Enabling this section adds an OpenTelemetry layer to the same tracing subscriber. It exports span trees to an OTLP collector without exporting, replacing, or redirecting the existing log events.

```toml
[openshell.gateway.otlp]
endpoint     = "http://otel-collector.observability.svc:4317"
service_name = "openshell-gateway"
```

`endpoint` is required and must be a valid URI. If it is malformed, the gateway logs the configuration error and continues with export disabled. It does not connect at startup: an unreachable collector produces export failures, never a failure to serve.

The transport is **OTLP over gRPC only**. HTTP/protobuf and HTTP/JSON are not supported, and `OTEL_EXPORTER_OTLP_PROTOCOL` has no effect. Point `endpoint` at a collector's gRPC receiver, conventionally port `4317`, not the HTTP receiver on `4318`. A URI alone cannot distinguish the two, so an HTTP endpoint is accepted at startup and then fails on export.

The OpenTelemetry SDK logs export failures after startup. Spans in a failed batch are dropped rather than retried.

`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`, `openshell.gateway.name` from the gateway's configured `name`, and `openshell.gateway.compute_driver`.

Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter.

The gateway forwards the OTLP configuration, configured gateway name, configured compute driver, and W3C trace context to managed external drivers. Built-in drivers also export their spans to the same collector through dedicated in-process providers. Driver spans retain the gateway trace context, use a distinct service name such as `openshell-driver-docker` or `openshell-driver-podman`, and carry the same `openshell.gateway.name` and `openshell.gateway.compute_driver` resource attributes as gateway spans. Compute-driver client and server spans use the same fully qualified protobuf operation name, such as `openshell.compute.v1.ComputeDriver/CreateSandbox`, in both the span name and `rpc.method`. The service name and span kind distinguish each side. Backend-prefixed child spans identify implementation work. A streaming watch records a terminal status when observed; consumer teardown without a terminal status leaves the span status unset. Operator-run external drivers own their own telemetry configuration.

For Helm deployments, set `server.otlp.endpoint` to render this table. The
optional `server.otlp.serviceName` value overrides the gateway service name;
driver service names remain fixed.

The local `mise run helm:k3s:create` workflow installs a trace collector and UI,
enables Agent Sandbox controller tracing on supported releases, and configures
both the controller and the existing Skaffold deployment to export traces to it
automatically.
The local `gateway`, `gateway:docker`, `gateway:podman`, and `gateway:vm` tasks
also set the gateway installation name in their generated configuration. Their
defaults are driver-specific (`kubernetes-dev`, `docker-dev`, `podman-dev`, and
`vm-dev`), and their documented `OPENSHELL_*_GATEWAY_NAME` overrides update both
CLI registration and exported telemetry identity.
Run `mise run helm:k3s:forward` to forward OTLP/gRPC to local port `4317` and
the trace UI to local port `18888`. When Skaffold has deployed a Kubernetes
gateway, the task also forwards it to local port `8090`; otherwise it continues
with the collector ports only. The Skaffold run task registers the forwarded
gateway under the worktree-specific k3d cluster name; select it with
`openshell gateway select <name>`. The local Podman, Docker, and VM gateway
tasks export to the forwarded receiver automatically.

In-process compute drivers read their backend-specific settings from `[openshell.drivers.<name>]`. An external driver's gateway table supplies only its `socket_path`; configure the external driver process itself through that binary's flags or environment variables. A driver that advertises `supports_sandbox_authentication` may authenticate an opaque bootstrap credential through the compute-driver protocol. The gateway trusts the returned sandbox ID only for `IssueSandboxToken`, verifies that the sandbox still exists, and then mints its own JWT. The in-process Kubernetes driver reads `service_account_name`, `workspace_mode`, and namespace discovery from `[openshell.drivers.kubernetes]`; an external Kubernetes driver receives the equivalent values through its own CLI or environment contract.

### Tuning

This table decides whether and where to export. How the SDK exports is controlled by the standard OpenTelemetry environment variables, which the gateway reads through the SDK rather than mirroring as TOML keys:

| Variable                                                                                                          | Effect                                                                 |
| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `OTEL_TRACES_SAMPLER`, `OTEL_TRACES_SAMPLER_ARG`                                                                  | Sampling strategy and ratio. Defaults to `parentbased_always_on`.      |
| `OTEL_BSP_SCHEDULE_DELAY`, `OTEL_BSP_MAX_QUEUE_SIZE`, `OTEL_BSP_MAX_EXPORT_BATCH_SIZE`, `OTEL_BSP_EXPORT_TIMEOUT` | Batch span processor tuning.                                           |
| `OTEL_RESOURCE_ATTRIBUTES`                                                                                        | Additional resource attributes, such as `deployment.environment=prod`. |
| `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, `OTEL_SPAN_EVENT_COUNT_LIMIT`, `OTEL_SPAN_LINK_COUNT_LIMIT`                    | Per-span limits.                                                       |
| `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_COMPRESSION`, `OTEL_EXPORTER_OTLP_TIMEOUT`                      | Exporter transport tuning.                                             |
| `OTEL_EXPORTER_OTLP_PROTOCOL`                                                                                     | No effect. The gateway is built with the gRPC exporter only.           |

To sample 10% of traces:

```shell
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1
```

`OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` are deliberately ignored. Enablement has one source, so an environment variable cannot silently turn export on or redirect it. `OTEL_RESOURCE_ATTRIBUTES` does apply and adds attributes, but a `service_name` set here wins over `OTEL_SERVICE_NAME`.

The gateway flushes buffered spans during shutdown, so spans from in-flight requests survive a `SIGTERM`.

## Supervisor Middleware Services

Register operator-run supervisor middleware services with one or more `[[openshell.supervisor.middleware]]` entries. Registration is static and operator-owned; changing it requires restarting the gateway.

```toml
[[openshell.supervisor.middleware]]
name = "local-content-guard"
grpc_endpoint = "https://host.openshell.internal:50051"
tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem"
audience = "urn:openshell:middleware:local-content-guard"
max_payload_bytes = 262144
timeout = "500ms"
```

Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`, so a service can inspect HTTP, WebSocket, or both. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name.

The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence.

`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and replacement bodies as well as complete WebSocket text messages and replacements. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport.

`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. An accepted WebSocket stream has no connection-wide RPC deadline.

The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:<name>`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest.

When `gateway_jwt` is configured, OpenShell attaches short-lived bearer credentials to gateway and supervisor calls and requires `https://`. A middleware endpoint must be reachable from sandbox supervisors, so Unix sockets are not an option here. Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint: OpenShell then attaches no credential, supervisors do not request one, and the gateway logs a warning naming the registration at every startup. mTLS client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places.

See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, failure, payload-limit, and operational guidance.

## Gateway Interceptors

`[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target only methods in the gateway's built-in allowlist of unary mutation RPCs. New RPCs are non-interceptable until they are deliberately added to that allowlist; adding one does not require handler-specific interceptor code. Request bodies are exposed as protobuf JSON objects. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object.

HTTPS interceptor endpoints use the platform trust store by default. Set `tls_ca_cert_path` to a PEM certificate bundle for a private CA; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:interceptor:<name>`. After authenticated `Describe` succeeds, the gateway treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. When `gateway_jwt` is configured, network interceptors must use HTTPS and receive short-lived gateway-caller bearer credentials; local Unix sockets are also supported. Set `allow_insecure_transport = true` to keep a plaintext `http://` interceptor endpoint with no credential attached and a startup warning.

### Extension Token Verification Endpoints

When `gateway_jwt` is configured the gateway publishes two unauthenticated documents that extension services use to verify OpenShell callers:

| Path                                | Contents                                                                                                                               |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `/.well-known/jwks.json`            | Single-key JWKS holding the Ed25519 public key and its `kid`.                                                                          |
| `/.well-known/openid-configuration` | OIDC-shaped discovery metadata: the exact expected `iss`, an absolute `jwks_uri`, and `EdDSA` as the only supported signing algorithm. |

The discovery document is OIDC-shaped rather than OIDC-compliant: `issuer` is the gateway identity (`openshell-gateway:<gateway_id>`), not the URL the document is served from. Verifiers compare `iss` against that value and must not infer trust from the document's location; the serving TLS connection is what authenticates the gateway. Both endpoints return `404` when `gateway_jwt` is not configured.

`binding_policy` controls how the manifest and operator binding configuration combine:

* `dynamic` enables valid manifest bindings and treats configured entries as optional narrowing overrides. This is the compatibility default. The gateway logs a startup warning because the interceptor controls its non-secret RPC authority.
* `allowlist` enables only configured RPC selectors and phases. The gateway ignores and logs extra manifest declarations, but fails startup when the manifest omits a configured RPC or phase.
* `exact` requires the configured and manifest RPC selectors and phases to match exactly.

Bindings under `allowlist` and `exact` require `rpc` or `service` plus `method`, and a nonempty `phases` list. They match by RPC rather than manifest binding ID. Duplicate selectors, `id`, and `disabled` are invalid in these modes; omit a binding to disable it. Binding failure policy comes from the binding, then the configured service, then defaults to `fail_closed`. Manifest failure policies do not override strict-mode operator configuration.

Bindings that include `post_commit` must resolve to `failure_policy = "fail_open"`; the gateway rejects fail-closed post-commit configuration at startup. Post-commit evaluation is observational. If response observation or interceptor evaluation fails after a handler succeeds, the gateway logs and counts the failure but returns the committed response unchanged.

`provider_profile_sources` selects the exact ordered provider-profile source set. Omit it to use the built-in and user-managed sources. Use `{ type = "builtin" }`, `{ type = "user" }`, and `{ type = "interceptor", name = "<configured-interceptor-name>" }` entries to compose a catalog. An interceptor entry must reference a configured service whose manifest advertises `provider_profiles = true`. Selecting only an interceptor makes that catalog authoritative; include local sources explicitly to compose them. Empty or duplicate source lists, unknown interceptor names, and duplicate normalized profile IDs fail closed. Source order controls collection and diagnostics, not override precedence.

For an authoritative interceptor catalog, select only that interceptor:

```toml
[openshell.gateway]
provider_profile_sources = [
  { type = "interceptor", name = "provider-governance" },
]
```

The gateway validates snapshot structure and provider-profile semantics. It treats a configured interceptor as the source trust boundary and does not verify signature, hash, or key annotations in profile payloads.

`failure_policy` accepts `fail_closed` or `fail_open`. `timeout` accepts `ms` and `s` suffixes. In `dynamic` mode, binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy.

`image_pull_policy` is a shared driver setting with the canonical values `always`, `if_not_present`, `never`, and `newer`. Set it inside the relevant driver table. Drivers translate these values to their runtime APIs; `newer` is supported only by Podman and is rejected at Docker and Kubernetes startup.

## Credential Drivers

Set `credential_drivers` only when the gateway should store provider secrets in an external credential backend. Provider secrets include injectable credentials and gateway-only refresh material such as OAuth refresh tokens, client secrets, and service-account private keys. OpenShell supports at most one enabled credential driver at a time. When `credential_drivers` is omitted, the gateway uses its default encrypted database credential storage. `credential_drivers = []` is invalid in the TOML file; omit the field for the default encrypted store, or select a backend such as `kubernetes-secrets` or `vault`.

Credential driver tables are backend-owned and live under `[openshell.credential_drivers.<name>]`. Built-in drivers default to in-tree transport, so they do not need a `transport` field. Use `transport = "uds"` with an absolute `socket_path` only for a remote gRPC driver over a Unix domain socket.

```toml
[openshell.gateway.credential_storage]
key_encryption_key_path = "/var/lib/openshell/credentials/key-encryption-key.bin"
```

For Kubernetes Secrets:

```toml
[openshell.gateway]
credential_drivers = ["kubernetes-secrets"]

[openshell.credential_drivers.kubernetes-secrets]
namespace = "openshell"
```

For Vault instead:

```toml
[openshell.gateway]
credential_drivers = ["vault"]

[openshell.credential_drivers.vault]
address = "http://vault.vault.svc.cluster.local:8200"
mount = "secret"
kv_version = "2"
auth_method = "kubernetes"
role = "openshell-gateway"
service_account_token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
```

For the default encrypted database store, OpenShell stores provider credentials as JSON envelopes encrypted with AES-256-GCM in the gateway database. Each credential gets a random data-encryption key; the gateway wraps that key with a local key-encryption key. By default, the key-encryption key is created at `$XDG_STATE_HOME/openshell/gateway/credentials/key-encryption-key.bin` with owner-only permissions. Use `[openshell.gateway.credential_storage] key_encryption_key_env` instead of `key_encryption_key_path` to load a base64-encoded 32-byte key-encryption key from an environment variable. Back up the database and key-encryption key together; losing either makes stored credentials unrecoverable. In Kubernetes, the Helm chart creates a retained Secret containing the shared key-encryption key, injects it as `OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY`, and renders `key_encryption_key_env` for the gateway when no external credential driver is enabled. Multi-replica deployments need every replica to use the same database and key-encryption key; the chart default handles the key-encryption key side.

For GitOps and `helm template` workflows where `lookup` returns empty, the chart-generated KEK Secret gets a random value on every render, making credentials unrecoverable. Set `server.credentialStorage.existingSecret` to the name of a pre-provisioned Secret containing the key-encryption key under the `key-encryption-key` data key. When set, the chart skips KEK Secret generation and references the provided Secret directly.

```yaml
server:
  credentialStorage:
    existingSecret: my-preprovisioned-kek-secret
```

For `kubernetes-secrets`, `namespace` sets where OpenShell-managed provider Secret objects are stored. When omitted, the driver uses the in-cluster ServiceAccount namespace when available, otherwise `default`. The Helm chart creates a Role granting the gateway access to all Secrets in the credential namespace because OpenShell-managed Secret names are dynamic SHA-256 hashes that cannot be restricted with `resourceNames`. Deploy credential Secrets in a dedicated namespace (`server.credentialDrivers.kubernetesSecrets.namespace`) to limit the RBAC blast radius.

For `vault`, `address` points at the Vault service, `mount` and `kv_version` describe the KV engine where OpenShell-managed provider secrets are stored, and `auth_method = "kubernetes"` logs in with the gateway Pod's ServiceAccount token. For local or development validation, use `auth_method = "token_file"` with `token_path = "/path/to/token"`. Do not put literal Vault tokens in TOML.

Provider records that already contain inline database credentials remain readable for upgrade compatibility. New provider create/update requests still submit credential values through the normal API, but the gateway stores those values through the active credential storage path and persists only handles. Before OpenShell 0.1.0, OpenShell does not automatically migrate inline refresh material or credential handles between drivers. Reconfigure refresh grants after an upgrade. Before changing credential drivers, remove affected credentials while the original driver is still available, then select the new driver and create them again. Do not run mixed gateway versions against the same refresh records.

For remote credential drivers, set `transport = "uds"` with `socket_path`. Omit `command`, `args`, and `startup_timeout_secs` when another service manager prestarts the driver socket. Keep backend tokens out of TOML; point the driver at mounted token files or native identity mechanisms instead.

The built-in `kubernetes-secrets` and `vault` drivers can also run out of
process over UDS. Set `command` to the standalone driver binary and pass
driver-specific settings through `args`; the gateway appends `--bind-socket <socket_path>` when it launches the process.

```toml
[openshell.gateway]
credential_drivers = ["kubernetes-secrets"]

[openshell.credential_drivers.kubernetes-secrets]
transport = "uds"
socket_path = "/run/openshell/credential-drivers/kubernetes-secrets.sock"
command = "/usr/libexec/openshell/openshell-driver-kubernetes-secrets"
args = ["--namespace", "openshell"]
```

```toml
[openshell.gateway]
credential_drivers = ["vault"]

[openshell.credential_drivers.vault]
transport = "uds"
socket_path = "/run/openshell/credential-drivers/vault.sock"
command = "/usr/libexec/openshell/openshell-driver-vault"
args = [
  "--address", "http://vault.vault.svc.cluster.local:8200",
  "--auth-method", "kubernetes",
  "--role", "openshell-gateway",
]
```

## Driver References

Each example is a complete TOML file for one compute driver. The examples repeat `[openshell]` and `[openshell.gateway]` so they stay copyable, and the driver tables list the accepted driver-specific keys. Drivers receive only their own tables, and the gateway rejects unknown gateway and driver fields.

Kubernetes configurations set `namespace`, `service_account_name`, and `enable_user_namespaces` in `[openshell.drivers.kubernetes]`. Docker configurations use `sandbox_label`; the legacy `sandbox_namespace` key is rejected.

### Kubernetes

The gateway runs as a Pod and creates sandbox Pods in another namespace. mTLS material for sandboxes is delivered through a Kubernetes Secret rather than host-side file paths.

```toml
[openshell]
version = 2

[openshell.gateway]
bind_address          = "0.0.0.0:8080"
health_bind_address   = "0.0.0.0:8081"
metrics_bind_address  = "0.0.0.0:9090"
log_level             = "info"
compute_driver        = "kubernetes"

[openshell.gateway.tls]
cert_path      = "/etc/openshell-tls/server/tls.crt"
key_path       = "/etc/openshell-tls/server/tls.key"
# client_ca_path is only rendered when server.tls.enableMtls is true (the
# default). When enableMtls is false — required for BackendTLSPolicy — the
# gateway runs HTTPS-only and this line is omitted by Helm.
client_ca_path = "/etc/openshell-tls/client-ca/ca.crt"
# When cert-manager serverIssuerRef is configured, these are populated by Helm:
# external_cert_path    = "/etc/openshell-tls/server-external/tls.crt"
# external_key_path     = "/etc/openshell-tls/server-external/tls.key"
# external_server_names = ["gateway.example.com"]

[openshell.drivers.kubernetes]
# Workspace isolation mode. "shared" renders all sandboxes into a single
# namespace. "managed" auto-creates a K8s namespace per workspace
# (openshell-{gateway_id}-{workspace}). "operator" maps each workspace to a
# pre-provisioned namespace discovered via label selector or drop-in file.
workspace_mode                 = "shared"
# Gateway identity used in managed-mode namespace naming. Defaults to the
# gateway JWT gateway_id. Must be a DNS-1123 label.
# gateway_id                   = "openshell"
namespace                      = "agents"
service_account_name           = "openshell-sandbox"
default_image                  = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
image_pull_policy              = "if_not_present"
image_pull_secrets             = ["regcred"]
# Defaults to the gateway version; override to pin a specific build.
# supervisor_image             = "ghcr.io/nvidia/openshell/supervisor:<version>"
supervisor_image_pull_policy   = "if_not_present"

# Use the image volume on Kubernetes >= 1.35 (GA in 1.36); switch to "init-container"
# on older clusters or where the ImageVolume feature gate is off.
supervisor_sideload_method     = "image-volume"
# "combined" runs the existing single supervisor container with full process,
# filesystem, and network enforcement in the agent container. "sidecar" moves
# pod-level network enforcement and gateway session handling into a network sidecar.
topology                       = "combined"
# Optional corporate HTTP forward proxy for policy-approved TLS egress. The
# sandbox workload cannot select or override these settings. Only http:// proxy
# endpoints and TLS CONNECT traffic are supported; plain HTTP egress remains
# direct. `no_proxy` bypasses only the corporate proxy, never OpenShell policy.
# https_proxy                  = "http://proxy.corp.example:8080"
# no_proxy                     = ".svc,.svc.cluster.local,10.96.0.0/12,10.244.0.0/16"
# Proxy credentials must be an existing Secret in the sandbox namespace. The
# key contains a `user:pass` value and is mounted only in the network
# supervisor container, never in workload environment or command arguments.
# proxy_auth_secret_name       = "corporate-proxy-auth"
# proxy_auth_secret_key        = "credentials"
# The gateway validates the Secret name/key syntax and their configuration
# relationship at startup; it does not read the Secret from the Kubernetes API.
# Kubernetes resolves the Secret when the Sandbox Pod starts. A missing key or
# Secret prevents that Pod from starting; unreadable or malformed `user:pass`
# content is validated fail-closed by the supervisor at startup and never
# falls back to direct egress.
# Proxy credential Secrets require `topology = "sidecar"`. Combined topology
# shares its credential mount with the workload and can make it readable by the
# sandbox group through Kubernetes `fsGroup` volume permission handling.
# Required with a credential Secret: Basic authentication to an http:// proxy
# is cleartext on the connection to that proxy.
# proxy_auth_allow_insecure    = true
# Last resort for hostname-filtering proxy ACLs. The proxy resolves the target,
# so its ACL becomes part of the egress boundary for proxied connections.
# proxy_connect_by_hostname    = true
# Required in raw gateway TOML because `namespace` identifies sandbox
# placement, not the gateway Service. Helm renders this from the release's
# gateway Service name and namespace.
grpc_endpoint                  = "https://openshell-gateway.openshell.svc:8080"
ssh_socket_path                = "/run/openshell/ssh.sock"
client_tls_secret_name         = "openshell-client-tls"
host_gateway_ip                = "10.0.0.1"
enable_user_namespaces         = false
app_armor_profile              = "Unconfined"
workspace_default_storage_size = "10Gi"
# Kubernetes StorageClass for the workspace PVC. Empty (default) omits the
# field, using the cluster's default StorageClass. Set this on clusters with no
# default StorageClass, otherwise the workspace PVC stays Pending.
# workspace_storage_class      = "fast-ssd"
# Kubernetes RuntimeClass applied to sandbox pods when the API request does
# not specify one. Empty (default) = omit the field, using the cluster default.
# default_runtime_class_name   = "kata-containers"
# Kubelet clamps projected tokens below 600 seconds. The driver caps values at 86400.
sa_token_ttl_secs              = 3600
# Optional SPIFFE Workload API socket mounted into sandbox pods for dynamic
# provider token grants. Use an absolute path under a dedicated directory;
# shared roots such as /run, /var, /tmp, and /etc are rejected.
# Supervisor-to-gateway auth still uses gateway JWTs.
provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.sock"
# Explicit sandbox UID/GID for the supervisor container securityContext and
# PVC init container. When unset, the driver auto-detects from OpenShift SCC
# namespace annotations (openshift.io/sa.scc.uid-range) if present, falling
# back to 1000 on non-OpenShift clusters. Any non-root Linux UID/GID is valid.
# sandbox_uid = 1500
# sandbox_gid = 1500
# Operator-mode namespace discovery. At least one must be set when
# workspace_mode = "operator". Both can be combined.
# operator_namespace_label discovers namespaces matching a K8s label selector.
# operator_namespace_label     = "openshell.ai/workspace=true"
# operator_namespace_file reads allowed namespaces from a JSON/YAML file
# (hot-reloaded on change, e.g. via ConfigMap volume mount).
# operator_namespace_file      = "/etc/openshell/workspace-namespaces.json"

[openshell.drivers.kubernetes.managed_ssh_ingress]
enabled = true
gateway_namespace = "openshell"
gateway_pod_selector = { "app.kubernetes.io/name" = "openshell", "app.kubernetes.io/instance" = "openshell" }

[openshell.drivers.kubernetes.sidecar]
# UID used by relaxed long-running network sidecars. Strict process/binary-aware
# sidecars run as UID 0 so Kubernetes grants the required /proc inspection
# capabilities into the effective set. In sidecar topology the network init
# container installs nftables rules that exempt the effective sidecar UID, so
# this dedicated infrastructure UID must remain at least 1000 and must not
# match the sandbox workload UID.
proxy_uid                      = 1337
# Keep process/binary-aware network policy enabled in sidecar topology. Set
# false to run the sidecar as proxy_uid, drop the sidecar's extra /proc
# inspection capabilities, and enforce endpoint/L7 policy without matching
# policy.binaries.
process_binary_aware_network_policy = true
```

In managed workspace mode, the Kubernetes driver copies each explicitly named
`image_pull_secrets` Secret from `namespace` into the managed workspace
namespace on sandbox creation. Shared and operator modes require the Secret to
already exist in the sandbox namespace.

For token-exchange provider profiles, the gateway also needs access to its own
SPIFFE Workload API socket. In Helm deployments, set
`server.providerTokenGrants.spiffe.enabled=true`; the chart mounts the socket
into the gateway pod and sets `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`.
The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the
SPIFFE Workload API, so this validation path does not require gateway access to
the SPIRE OIDC discovery endpoint or its TLS CA.

### MXC

The MXC driver runs Windows workloads through `wxc-exec`. Enable ETW auditing to
map Windows Sandboxing provider events into the gateway's OCSF stream.

```toml
[openshell]
version = 1

[openshell.gateway]
bind_address    = "127.0.0.1:17670"
log_level       = "info"
compute_drivers = ["mxc"]

[openshell.drivers.mxc]
wxc_exec_path           = "C:\\mxc-kit\\bin\\wxc-exec.exe"
backend                 = "process_container"
default_configuration_id = "composable"
pc_least_privilege      = false
pc_capabilities         = []
# Pattern-C governed egress. The address is a loopback seed; the driver
# allocates a unique ephemeral port for each sandbox.
egress_proxy            = false
egress_proxy_addr       = ""
debug                   = false
etw_audit               = true
```

`etw_audit` defaults to `false`. When enabled, the gateway account must be an
administrator or belong to the Windows Performance Log Users group. Workload
commands and working directories remain sandbox-scoped and must be supplied in
the `mxc` driver configuration when creating a sandbox.

Set `egress_proxy = true` with a loopback seed such as
`egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split.
MXC redirects sandbox traffic to a sandbox-unique port on the configured
loopback IP, where the host CONNECT proxy enforces the trimmed network policy.
The driver exposes the proxy's public CA bundle through a sandbox-unique
internal filesystem grant so HTTPS clients can read the injected trust path;
the CA private key remains in gateway memory.
Governed egress requires `process_container` until MXC supports `network.proxy`
for isolation sessions.
MXC rejects policies containing `network_middlewares` before launch because
this host-proxy path does not yet receive the gateway's built-in and remote
middleware registry.

The driver records executable identity in process audit events and omits raw
command arguments because they can contain credentials or personal data. See
[OCSF JSON Export](/observability/ocsf-json-export) for durable Windows audit
output.

### Docker

Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required). Configure guest mTLS paths once under `[openshell.gateway]`; the gateway validates and injects the bundle into the selected local driver.

```toml
[openshell]
version = 2

[openshell.gateway]
bind_address    = "127.0.0.1:17670"
log_level       = "info"
compute_driver = "docker"
# Gateway-owned bundle injected into the selected local driver.
guest_tls_ca  = "/etc/openshell/certs/ca.pem"
guest_tls_cert = "/etc/openshell/certs/client.pem"
guest_tls_key  = "/etc/openshell/certs/client-key.pem"

[openshell.drivers.docker]
socket_path        = "/var/run/docker.sock"
default_image      = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
# Canonical values: always | if_not_present | never. `newer` is Podman-only.
image_pull_policy  = "if_not_present"
# Value assigned to the openshell.sandbox_namespace label on sandbox containers.
sandbox_label      = "docker-dev"
# Optional override. When omitted, the gateway derives
# https://host.openshell.internal:<gateway-port> for this topology.
grpc_endpoint      = "https://host.openshell.internal:17670"
# Skip the image-pull-and-extract step by pointing at a locally built binary.
supervisor_bin     = "/usr/local/libexec/openshell/openshell-sandbox"
# When supervisor_bin is omitted, Docker extracts /openshell-sandbox from this image.
# Defaults to the gateway version; override to pin a specific build.
# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:<version>"
network_name       = "openshell-docker"
host_gateway_ip    = "172.17.0.1"
ssh_socket_path    = "/run/openshell/ssh.sock"
# Unsafe operator override. Host bind mounts, including Docker local-driver
# bind-backed volumes, expose gateway-host paths inside sandboxes and can
# negate OpenShell isolation and filesystem controls.
enable_bind_mounts = false
# Omit to use OpenShell's 2048-process default. Explicit 0 is invalid.
sandbox_pids_limit = 2048
# Explicit supervisor-compatible default. RuntimeDefault requires Docker to
# report AppArmor support; Localhost/<name> requires an operator-loaded profile.
app_armor_profile = "Unconfined"
# Corporate TLS egress proxy. These are supervisor argv settings, not workload
# environment variables. Do not embed credentials in the URL.
https_proxy = "https://proxy.corp.example:8443"
no_proxy = ".svc.cluster.local,10.0.0.0/8"
# Optional root-owned host file containing user:pass. An http:// proxy also
# requires proxy_auth_allow_insecure = true as an explicit acknowledgement.
proxy_auth_file = "/etc/openshell/secrets/proxy-auth"
# Project a host Unix Workload API socket into the supervisor for provider
# token exchange. The socket parent must be a dedicated absolute directory.
provider_spiffe_workload_api_socket = "/run/spire/agent.sock"
```

Use `sandbox_label` for Docker configurations. The legacy
`sandbox_namespace` key is rejected.

### Podman

Sandboxes run as Podman containers on a user-mode bridge network. The supervisor image is mounted read-only via Podman's `type=image` mount. Configure guest mTLS paths once under `[openshell.gateway]`; the gateway validates and injects the bundle into the selected local driver.

```toml
[openshell]
version = 2

[openshell.gateway]
bind_address    = "127.0.0.1:17670"
log_level       = "info"
compute_driver = "podman"
# Gateway-owned bundle injected into the selected local driver.
guest_tls_ca  = "/etc/openshell/certs/ca.pem"
guest_tls_cert = "/etc/openshell/certs/client.pem"
guest_tls_key  = "/etc/openshell/certs/client-key.pem"

[openshell.drivers.podman]
# Rootless socket path. For root Podman use /run/podman/podman.sock.
# Omit to auto-detect: the driver probes for a responsive Podman socket, then
# asks the podman CLI where its socket is, and fails to start if neither finds
# one. Set this to pin a specific Podman machine instead.
socket_path             = "/run/user/1000/podman/podman.sock"
default_image           = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
image_pull_policy       = "if_not_present" # always | if_not_present | never | newer
# Optional override. When omitted, the gateway derives this endpoint.
grpc_endpoint           = "https://host.containers.internal:17670"
# The gateway overwrites gateway_port from bind_address at runtime.
gateway_port            = 17670
network_name            = "openshell"
# Omit for the platform default: empty on Linux, 192.168.127.254 on macOS Podman machine.
# Set "" to force Podman's host-gateway resolver.
# host_gateway_ip       = "192.168.127.254"
ssh_socket_path         = "/run/openshell/ssh.sock"
stop_timeout_secs       = 45
# Defaults to the gateway version; override to pin a specific build.
# supervisor_image      = "ghcr.io/nvidia/openshell/supervisor:<version>"
# Unsafe operator override. Host bind mounts, including Podman local-driver
# bind-backed volumes, expose gateway-host paths inside sandboxes and can
# negate OpenShell isolation and filesystem controls.
enable_bind_mounts      = false
# Omit to use OpenShell's 2048-process default. Explicit 0 is invalid.
sandbox_pids_limit      = 2048
# Health check interval in seconds. Omit to disable health checks; explicit 0
# is invalid. Lower values detect readiness faster but increase process churn
# (each check spawns a conmon subprocess).
health_check_interval_secs = 10
# User namespace mode for sandbox containers. Omit to use the default.
# Supported modes: auto, host, keep-id, no-map, private.
# userns                  = "auto"
# Explicit UID/GID mappings for userns = "private". Each entry is
# "container_id:host_id:size". Required when mode is "private"; rejected
# for other modes. Rootless Podman uses intermediate IDs (0:0:1, 1:1:65535);
# rootful Podman uses absolute host IDs (0:1000:1, 1:100000:65536).
# uidmap                  = ["0:0:1", "1:1:65535"]
# gidmap                  = ["0:0:1", "1:1:65535"]
# Corporate forward proxy for sandbox egress. When set, the in-container
# supervisor chains policy-approved TLS tunnels through this proxy with HTTP
# CONNECT instead of dialing destinations directly. Plain-HTTP requests are
# not proxied and always dial the destination directly. http:// and https://
# proxy URLs in explicit scheme://host:port form are supported: the scheme and
# port are both required, and a URL carrying a path, query, or fragment is
# rejected rather than silently truncated. For an https:// proxy the supervisor
# wraps the connection to the proxy in TLS before the CONNECT handshake,
# verifying the proxy certificate against the built-in and system roots plus
# the optional proxy_ca_bundle below.
# NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs, each with an
# optional :port qualifier) are dialed directly. A port-qualified entry only
# bypasses that destination port. IP and CIDR entries also match hostnames
# through their validated DNS resolution; such a match dials directly only
# the resolved addresses inside the entry. This is an operator-owned egress
# boundary:
# sandbox and template environment cannot override it, and the conventional
# HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it.
#
# The CONNECT request sent to the proxy targets a validated resolved IP,
# not the hostname, so the proxy performs no DNS resolution of its own and
# the tunnel stays bound to the address that passed the sandbox's SSRF and
# allowed_ips validation. The hostname still travels inside the tunnel (TLS
# SNI, application Host), so destination servers behave normally. In
# split-horizon networks, point the gateway host at the corporate resolver
# so internal names validate to their internal addresses. If the proxy's
# ACLs filter on hostnames and reject IP CONNECT targets, set
# proxy_connect_by_hostname = true as a last resort: the proxy then
# resolves the name itself, so a name that resolves differently at the
# proxy (split-horizon DNS, rebinding) can reach destinations the sandbox
# policy never approved, and the proxy's own ACLs become the effective
# egress control for proxied TLS.
#
# Configuration is fail-closed: an invalid proxy URL is rejected at gateway
# startup, setting no_proxy, proxy_auth_file, or proxy_connect_by_hostname
# without a proxy URL is rejected as well, and a set-but-invalid value
# reaching a sandbox (for example an unreadable or malformed auth file) is
# fatal to that sandbox's supervisor instead of silently falling back to
# direct or unauthenticated egress.
#
# Credentials must NOT be embedded in the URL (an inline user:pass@ is
# rejected at startup, since it would be stored here and exposed in container
# metadata). Instead point proxy_auth_file at a file containing "user:pass";
# the gateway delivers it to the supervisor through a root-only secret mount.
# The credential must use the user:pass form (non-empty user, no control
# characters); the same validation runs at sandbox-create time and in the
# supervisor, so a credential accepted here is never rejected in-container.
# Keep gateway.toml and the auth file owner-readable only (mode 0600).
#
# WARNING: with an http:// proxy the supervisor sends the credential as a
# Proxy-Authorization: Basic header over the plain-TCP connection. Basic
# auth is base64, not encryption: anyone on the network path between the
# sandbox host and the proxy can recover the credential. Because of that,
# proxy_auth_file with an http:// proxy requires the explicit
# acknowledgement proxy_auth_allow_insecure = true; without it the
# configuration is rejected at gateway startup. Only opt in when the path
# to the proxy is a trusted network segment. For an https:// proxy the
# credential is sent inside the verified TLS session, so the
# acknowledgement is not required (but tolerated if set).
#
# proxy_ca_bundle points at a PEM CA bundle (on the gateway host) trusted for
# the corporate proxy. A CA certificate is not secret, so the gateway
# bind-mounts it read-only into the sandbox. It is trusted in two places: the
# TLS handshake with an https:// proxy, and — because a TLS-intercepting proxy
# (mitmproxy, squid ssl-bump) re-signs tunneled server certificates with the
# same CA — the sandbox trust bundle and the supervisor's upstream
# re-encryption, so intercepted upstream TLS keeps working and sandbox
# workloads trust the re-signed certificates. It is valid with either an
# http:// or https:// proxy and requires a proxy URL; the file must exist and
# contain at least one certificate, or the sandbox fails closed at startup.
# https_proxy               = "http://proxy.corp.com:8080"
# no_proxy                  = "*.svc.cluster.local,10.0.0.0/8"
# proxy_auth_file           = "/etc/openshell/secrets/proxy-auth"
# proxy_auth_allow_insecure = true
# Last resort for hostname-filtering proxy ACLs; see the warning above.
# proxy_connect_by_hostname = true
# Corporate CA trusted for an https:// proxy and TLS-intercepting proxies.
# proxy_ca_bundle           = "/etc/openshell/tls/proxy-ca.pem"
# Project a host Workload API Unix socket into the supervisor, or use an
# explicit container-reachable TCP endpoint, for provider token exchange.
# provider_spiffe_workload_api_socket = "/run/spire/agent.sock"
# provider_spiffe_workload_api_socket = "tcp:169.254.1.2:8081"
# Omit app_armor_profile to preserve Podman's runtime-selected profile.
# Set Unconfined only when the supervisor's mount setup requires it.
# Explicit RuntimeDefault and Localhost/<name> require Podman to report
# AppArmor support.
# app_armor_profile = "Unconfined"
```

Use `ssh_socket_path` for Podman configurations. The legacy
`sandbox_ssh_socket_path` key is rejected. When `app_armor_profile` is omitted,
OpenShell sends no override and Podman applies its runtime-selected profile.
Set `Unconfined` explicitly only when the deployment requires the supervisor's
mount setup to bypass that profile.

### MicroVM

Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone.

```toml
[openshell]
version = 2

[openshell.gateway]
bind_address    = "127.0.0.1:17670"
log_level       = "info"
# VM is never auto-detected; an explicit entry here is required.
compute_driver = "vm"
# Gateway-owned bundle injected into the selected local driver.
guest_tls_ca  = "/var/lib/openshell/guest-tls/ca.pem"
guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem"
guest_tls_key  = "/var/lib/openshell/guest-tls/client-key.pem"

[openshell.drivers.vm]
state_dir        = "/var/lib/openshell/vm"
# Where the gateway looks for the openshell-driver-vm subprocess binary.
driver_dir       = "/usr/local/libexec/openshell"
default_image    = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
# Optional override. When omitted, the gateway derives
# https://host.openshell.internal:<gateway-port> for the VM topology.
grpc_endpoint    = "https://host.openshell.internal:17670"
# Empty falls back to default_image.
bootstrap_image  = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
krun_log_level   = 1
vcpus            = 2
mem_mib          = 2048
overlay_disk_mib = 4096
# Resolved sandbox UID/GID for new rootfs /etc/passwd entries.
# Defaults to the image's sandbox account, or 1000 when the account is absent;
# matching GID is used if sandbox_gid is empty. Persisted overlays recover their
# recorded identity, including 10001, rather than receiving a legacy fallback.
# Values must fall within OpenShell's allowed non-root sandbox identity range.
# sandbox_uid = 20001
# sandbox_gid = 20001
# Corporate forward proxy for sandbox egress. The keys, their semantics, and
# the fail-closed contract are identical to the Podman driver above: only TLS
# (CONNECT) egress is chained, plain-HTTP destination requests always dial
# directly, credentials must come from proxy_auth_file rather than the URL,
# an http:// proxy with credentials requires proxy_auth_allow_insecure, and
# any present-but-invalid value is rejected at gateway startup rather than
# degrading to a direct dial. proxy_auth_file is a path on the gateway host.
#
# The sandbox cannot select or override these settings. They reach the guest
# supervisor through a protected per-sandbox argument file the driver writes
# into the overlay upperdir on every launch, so a sandbox image cannot supply
# its own values or disable the operator's by baking a file at that path.
#
# Reachability: a proxy on the corporate network needs no special address and
# works on every VM sandbox. The guest's callback to the gateway is unaffected
# and never traverses the proxy.
#
# A proxy on the gateway host itself is reachable only from libkrun-backed
# (non-GPU) sandboxes: their egress leaves through gvproxy, which NATs
# 192.168.127.254 to the host's 127.0.0.1, so address it as
# http://host.openshell.internal:<port> rather than http://127.0.0.1:<port>.
# GPU sandboxes run on the QEMU/TAP backend, which has no such NAT —
# host.openshell.internal resolves to the TAP host address, and the driver's
# nftables rules let the guest reach only the gateway port on the host. A
# gateway-host proxy URL is therefore rejected when the sandbox launches on
# QEMU, rather than timing out on every CONNECT; give GPU sandboxes a proxy
# address routable from the guest's masqueraded egress.
#
# Because a microVM has no bind mounts or container secrets, the driver stages
# the credential and optional CA bundle into the per-sandbox overlay disk and
# removes them with the sandbox. The credential is root-only; the non-secret CA
# bundle is mode 0644. Both are referenced from the protected supervisor
# argument file by fixed guest paths, never their gateway-host paths. The
# credential is therefore at rest in that overlay image on the gateway host —
# the same delivery the per-sandbox gateway token already uses, and a difference
# from the Podman secret model worth noting when choosing where to keep proxy
# credentials.
# https_proxy               = "http://host.openshell.internal:8080"
# no_proxy                  = "10.0.0.0/8,.internal.example"
# proxy_auth_file           = "/etc/openshell/secrets/proxy-auth"
# An http:// proxy with proxy_auth_file requires this explicit acknowledgement:
# proxy_auth_allow_insecure = true
# Last resort for hostname-filtering proxy ACLs; see the Podman section above.
# proxy_connect_by_hostname = true
# Gateway-host PEM bundle trusted for an https:// proxy and for server
# certificates re-signed by a TLS-intercepting proxy. Requires https_proxy.
# proxy_ca_bundle           = "/etc/openshell/tls/proxy-ca.pem"
# VM guests cannot mount a host Workload API Unix socket. Configure only a
# separately operated guest-reachable TCP listener and explicitly acknowledge
# the exposure; host-only sockets are never exposed automatically.
# provider_spiffe_workload_api_tcp_endpoint = "tcp:192.0.2.10:8081"
# provider_spiffe_allow_guest_tcp = true
# Where the gateway stages rootfs tar archives for `--from ./rootfs.tar`.
# Defaults to <state_dir>/rootfs-tar-staging. The gateway creates one
# request-scoped subdirectory per staging slot and removes it after use.
# rootfs_tar_staging_dir = "/var/lib/openshell/vm/rootfs-tar-staging"
# Largest rootfs tar archive the driver accepts, in bytes. Defaults to 10 GiB.
# Gzip archives are decompressed while staging, and the limit also bounds the
# expanded tar.
# rootfs_tar_max_bytes = 10737418240
```

Rootfs tar staging requires the gateway and the VM driver to share a filesystem
and run as the same user. That holds for the managed VM driver, which the
gateway starts as a subprocess. If you point `compute_driver_endpoints` at an
externally managed `vm` socket owned by another user, the driver cannot read the
gateway's staging directory and rootfs tar sources fail with a
`FAILED_PRECONDITION` error; use a registry image reference instead.

### Extension Driver

Extension drivers run outside the gateway and expose the
`compute_driver.proto` gRPC service on a Unix socket. Use a non-reserved driver
name; built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot
be selected through unmanaged socket endpoints. The selected driver name is the
key used for driver-owned sandbox config such as `template.driver_config.<name>`.

```toml
[openshell]
version = 2

[openshell.gateway]
bind_address    = "127.0.0.1:17670"
log_level       = "info"
compute_driver = "kyma"

[openshell.drivers.kyma]
socket_path = "/run/openshell/kyma-compute-driver.sock"
```

## Preflight package configuration \{#gateway-config-preflight}

Before starting a package-managed gateway, validate the selected file without
changing it:

```shell
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
```

Without `--path`, the command validates a nonempty `OPENSHELL_GATEWAY_CONFIG`.
Otherwise, it validates an existing XDG gateway config when one is discovered.
When neither source selects a config, preflight succeeds. An explicit missing path,
a legacy schema-v1 file, invalid TOML, a symlink, or any nonregular file fails.
Preflight merges the selected file with the current `OPENSHELL_*` environment and
applies the daemon's read-only startup checks. These checks include selector and
socket normalization, registered-driver selection and configuration, rate-limit
pairs, TLS and mTLS relationships, interceptor registrations, and supervisor
middleware registrations. When a selected file omits `compute_driver`, preflight
validates each configured table for an auto-detectable driver without running the
runtime detection probes, which can connect local sockets or launch discovery
commands. It validates complete guest TLS path sets without requiring
package-generated certificates to exist before certificate generation. It does
not construct a compute driver or connect to a transport. A failed
preflight always preserves the file; it never migrates, replaces, or rewrites
configuration.

To validate the exact daemon arguments that a wrapper will pass, place them after
`--` instead of using `--path`:

```shell
openshell-gateway config preflight -- --config /etc/openshell/gateway.toml --grpc-rate-limit-requests 100 --grpc-rate-limit-window-seconds 60
```

Debian and Ubuntu run preflight from the systemd user unit before local certificate
generation. The unit still loads the `gateway.env` environment file and starts the
gateway with no configuration arguments. Snap replays the exact effective daemon
arguments through preflight. It gives a nonempty `OPENSHELL_GATEWAY_CONFIG`
precedence; otherwise it validates and passes its canonical
`SNAP_COMMON/gateway.toml` only when that path exists in the filesystem. A broken
symlink is therefore rejected instead of being treated as absent.

Package startup does not modify an operator-owned v1 file. Back it up, follow
[Migrate to schema version 2](#migrate-to-schema-version-2), then validate the
result explicitly before restarting the service:

```shell
cp ~/.config/openshell/gateway.toml ~/.config/openshell/gateway.toml.v1.bak
$EDITOR ~/.config/openshell/gateway.toml
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
systemctl --user restart openshell-gateway
```