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.
Install
Section titled “Install”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>.
$ 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.yamlThe 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.
The three values you must set
Section titled “The three values you must set”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: 4guardrails 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.
The one you must decide
Section titled “The one you must decide”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.
Verify
Section titled “Verify”An accepted EnvoyFilter that matched no listener is silent, so check
the proxy rather than the CRD:
$ istioctl proxy-config listener <gateway-pod> -n istio-system -o json \ | grep pistra.guardrailsOn GKE, acceptance is asynchronous:
$ kubectl describe gcptrafficextension guardrails-pistra-guardrails -n pistra-systemThen prove it end to end, through the gateway:
$ 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.
After a policy change
Section titled “After a policy change”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.
One release for both roles
Section titled “One release for both roles”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.
Where to look next
Section titled “Where to look next”- 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.