Skip to content

Deploy guardrails beside your gateway

Put pistra’s guardrails on the traffic a gateway you already run is carrying, Istio, GKE, any Envoy, without giving pistra anything to route or any credential to hold. This is pistra in its guardrails-only role (the code calls it the inspector: it inspects, and by construction cannot route), installed from the pistra-guardrails chart.

This is the Kubernetes version of Guard traffic you don’t route. Run that first if you have not. It is the same component with a static Envoy and a curl, and ten minutes there saves an afternoon here.

The chart path is a checkout, because pistra is pre-release and nothing is tagged. There is no published chart to install from yet. From the first release this becomes oci://ghcr.io/pistra-dev/pistra/charts/pistra-guardrails --version <release>.

Terminal window
$ helm install pistra-guardrails deploy/charts/pistra-guardrails \
--namespace pistra-system --create-namespace \
--set image.repository=your.registry/pistra \
--set proxyIntegration=istio \
--set istio.gatewayNamespace=istio-system \
--set istio.gatewaySelector.istio=ingressgateway \
--values my-guardrails.yaml

The chart installs a Deployment, ConfigMap, Service, ServiceAccount and PodDisruptionBudget, then writes the gateway-side wiring: an Istio EnvoyFilter, a GKE GCPTrafficExtension, a kgateway GatewayExtension + TrafficPolicy, an Envoy Gateway EnvoyExtensionPolicy, or, with proxyIntegration: none, nothing at all, for a gateway you configure yourself.

There is no image in this repository to point at. Build one from ./cmd/pistra and push it somewhere your cluster can pull from. The chart deliberately ships no default registry, because a default nobody publishes is an ImagePullBackOff at 3am instead of an error message at install.

image.repository, above.

The gateway. Under proxyIntegration: istio, that is istio.gatewaySelector (the gateway pods’ labels, plus istio.gatewayNamespace if the gateway does not live beside the release). Under gke, kgateway and envoy-gateway, it is the targetRef.name of the Gateway (plus its namespace when the Gateway does not live beside the release, in which case the chart also writes the ReferenceGrant that admits the reference). All are required rather than defaulted, for the same reason. The failure they prevent is silent. An EnvoyFilter with no workloadSelector patches every proxy in the namespace. An extension attached to no Gateway installs cleanly and inspects nothing.

Your policy, under deployment:. The whole key is a pistra deployment document, verbatim, the same YAML the tutorial writes to guardrails.yaml:

deployment:
guardrails:
detectors:
- type: pii
entities: [CREDIT_CARD, US_SSN]
rules:
- name: block-cards
when: '"CREDIT_CARD" in entities'
action: deny
message: card numbers must not leave the network
- name: mask-the-rest
action: redact
operator: mask
mask_chars: 4

guardrails is required and the chart says so at template time. Pistra refuses to start an inspector with no policy, because it would buffer every request body to do nothing with it.

providers is refused outright. A config with providers would make this Deployment a router holding upstream credentials, the confusion the inspector role exists to prevent. So are xds and extproc.listen, keys from a removed Envoy mode that the binary no longer knows. listen and extproc.inspect_listen are written by the chart from ports.

extProc.failureModeAllow. It defaults to false.

Fail-closed means that when the inspector is unreachable, requests fail. Fail-open means they succeed, unscanned, with a 200, and the only trace is a gateway counter. The default is false because the second outcome is the event the deployment exists to prevent, and it is invisible.

The cost is not hidden. While the Deployment is unreachable, every request through the selected gateway fails, not just LLM traffic, because an EnvoyFilter patches a listener, not a route. The chart pays for that with two replicas, a PodDisruptionBudget, topology spread, a preStop delay and a graceful stop, but the mitigation that matters most is scope. Select only the gateway carrying the traffic you are guarding, and narrow further inside pistra with the path policy variable:

- name: block-cards
when: 'path.startsWith("/api/tickets") && "CREDIT_CARD" in entities'

Under proxyIntegration: gke, gke.matchCondition narrows at the gateway instead, and there it is the only lever. GKE has no equivalent of allow_mode_override, so every matched request is a callout. Under kgateway and envoy-gateway, attach to one listener with targetRef.sectionName rather than the whole Gateway.

kgateway.filterStage is where the filter lands relative to the other TrafficPolicy filters on that Gateway (after AuthZ by default) and is the value to change if your chain differs. In front of an InferencePool, llm-d’s gateways are Istio, agentgateway, Envoy AI Gateway and GKE. On Istio the insertBeforeFilter rule above puts the guardrail ahead of the endpoint picker, and on agentgateway (proxyIntegration: agentgateway) an ext_proc policy runs before the pick by construction. agentgateway has no STREAMED body mode, so set config.extproc.request_body_mode: duplex there, FULL_DUPLEX_STREAMED. Prefer that mode on any Envoy that offers it. A held chunk has no message_timeout, so a remote: detector can take its time on it. It is also the only mode that can inspect a body that ends on HTTP trailers: in STREAMED the held chunks are already cleared when the trailers arrive, so the inspector refuses such a request with a 501 naming this setting and counts such a response as uninspected.

An accepted EnvoyFilter that matched no listener is silent, so check the proxy rather than the CRD:

Terminal window
$ istioctl proxy-config listener <gateway-pod> -n istio-system -o json \
| grep pistra.guardrails

On GKE, acceptance is asynchronous:

Terminal window
$ kubectl describe gcptrafficextension guardrails-pistra-guardrails -n pistra-system

Then prove it end to end, through the gateway:

Terminal window
$ curl -s <gateway>/api/tickets -H 'content-type: application/json' \
-d '{"note":"charge 4012888888881881 today","tags":["billing"]}'

The response looks like this:

{"error":{"code":"guardrail_denied","message":"card numbers must not leave the network","type":"pistra_error"}}

If that succeeds instead, the filter is not in the request path. Fix the selector or the insert point before touching the policy.

helm upgrade with an edited config: rolls the pods. It has to. Pistra applies configuration on SIGHUP, nothing watches the file, and nothing in the chart sends that signal, so the Deployment carries a checksum of the ConfigMap and a changed policy changes the PodSpec. Two policy versions are live during the roll.

If the same team owns the gateway and a pistra front door, the front-door chart can serve this listener from its own pods instead of a second Deployment. Set inspector.enabled: true there, and give it the same deployment.guardrails and the same inspector.proxyIntegration and inspector.istio/gke/kgateway/envoyGateway values you would give this chart. It then writes a <fullname>-inspect Service and the same gateway-side objects. The trade is the failure domain. A fail-closed gateway then depends on pods that roll for a provider change or a raft membership move. The front-door chart’s README says when that is worth it.

  • The chart’s own README has the full values table, both walkthroughs, and the sections on what reaches the inspector and why the chart creates no RBAC.
  • Threat model, what this defends against and what it does not. Worth reading before you write the rule that everything else depends on.
  • Schedules, if any of your rules use schedule: async, note that async enforcement rides an in-process cache and lands on the next turn, so it does not survive two replicas without session affinity.