Threat model
What pistra defends against, what it does not, and which claims are checkable rather than promised.
What it is
Section titled “What it is”A policy enforcement point in the request path. It authenticates clients with credentials it issued, decides whether a request may proceed, inspects content, and holds the provider credentials so clients never do. It is a control on traffic, not a security boundary around a network, and not a substitute for one.
Assets
Section titled “Assets”| asset | where it lives | protection |
|---|---|---|
| provider API keys | config file or env, in memory | never sent downstream; swapped in after all mutations; the client’s own credential is stripped |
| virtual keys | the raft log | stored as SHA-256 only; the token itself is never written anywhere |
| cluster state | raft log, boltdb | ACME private keys sealed with AES-GCM (HKDF-SHA256 from the cluster secret) before they are proposed |
| prompt content | in flight only | see below |
Prompt content is not retained
Section titled “Prompt content is not retained”This is checkable, so check it rather than believe it:
- No log statement carries a body, a prompt, a completion or a matched
span. Guardrail decisions log the rule, the action, the route and
entity counts,
entities=map[pii/CREDIT_CARD:1], never the text that matched. - Traces use the GenAI semantic conventions but carry no content attributes.
- The store schema holds virtual-key metadata and token aggregates. There is no column that could hold a prompt.
- Explanation traces (
explain: true) keep the matched pattern, not the matched text.
There are two honest exceptions. Guardrail CEL policy can read
a.text, the matched span, because a rule like “allow the company’s
own address” needs it. A rule you write can move matched text into a
decision, though not into a log. And the delta cache holds annotations
keyed by content hash in memory for the life of the process, bounded by
cache_entries.
What it defends against
Section titled “What it defends against”- Credential sprawl. Clients hold gateway-issued keys. Revoking one takes microseconds on a node and about 30 ms across a cluster, and no client ever held anything that works directly against the provider.
- Unbounded spend. Cost is reserved before a request is forwarded and settled against real usage after, so concurrent long streams cannot overrun a limit between checks.
- Sending content to a model that should not go there. Detectors
inspect request bodies in-process and policy denies or rewrites. With
schedule: sync, the provider never sees the content. Request headers and the query string are inspectable too (apply_to: [header, query]) and are deliberately read-only. Policy can deny or annotate on a credential in a URL, and no rule can rewrite one, because rewriting a query means rewriting the request’s path. Neither is inspected unlessapply_tonames it, because scanning them runs the ensemble over the request’s ownauthorizationheader. With the credential detectors enabled that annotates the key the request authenticated with, on every request. Scope the rule rather than the kind. Envelope annotations carry an addressableref, soa.ref != ".headers.authorization"keeps the credential in a query string and drops the one presented at the door. - Returning content to a user that should not come back. Response guards apply to unary bodies and to streams, holding a stream exactly as far as a match could reach.
- Silent capability loss. A channel that would drop a capability the request uses is refused by default, naming the capability.
- Metering drift. An optional poller audits wire-metered tokens against the provider’s own admin usage API and alerts on divergence. That check catches a scanner gap or out-of-band traffic on the same provider key.
Impersonation
Section titled “Impersonation”A caller presents one of two credentials, and neither is a name it asserts about itself.
A virtual key is 32 bytes from the system CSPRNG in base62, followed by a CRC-32 over that body. The gateway stores the hex SHA-256 and finds a key by hashing what was presented, so a reader of the raft log holds nothing that works. The checksum makes a truncated or mistyped token fail as a shape rather than as a lookup, and it is the check the published scanner pattern applies, so what the gateway calls a key and what a secret scanner calls a key never disagree.
A JWT is verified against its issuer’s JWKS and against the audience its plane was configured with. The admin API accepts one audience and the data plane accepts another, so a token minted to administer the gateway cannot send traffic through it, and a token minted for another resource server is refused with the challenge RFC 9728 describes. Roles come from a claim the issuer assigns, and the configuration refuses the claims known to be self-asserted, naming the one to use instead. Roles read from an editable claim would let a caller choose their own authority and leave an ordinary-looking trail. See authorization.
An A2A card the gateway projects is canonicalized under RFC 8785 and
signed with ES256, and every configured key is published at
/.well-known/a2a-jwks.json under its RFC 7638 thumbprint. A peer can
check that the card it discovered is the card this deployment served.
Configure no signing keys and cards go out unsigned, which a client that
requires a signature will refuse.
None of this establishes the person behind a key. A virtual key is a bearer token, and whoever holds it is that key until it is revoked. Bind a request to a human with a token from your identity provider rather than with a key.
Privilege escalation
Section titled “Privilege escalation”- The two planes cannot reach each other. A Cedar policy cannot read a request body. A CEL condition cannot authorize an admin call. The languages, the callers and the defaults differ on each side, and neither engine is reachable from the other’s input.
- The admin vocabulary is closed at compile time. An action, an
attribute or a resource class the gateway does not define is refused
when the policy file is loaded rather than when a request first hits
it. Cedar skips a policy that errors, so a typo inside a
forbidwould be aforbidthat silently stopped forbidding. That failure is moved to startup because it is the quiet one. - There is no built-in super-admin. No principal is trusted by the binary, and every admin decision comes from your policy file. The corollary is stated under what it does not defend against.
- A profile can only narrow. Deployment-wide access rules run before a profile’s, an allow in one list does not carry into the other, and a deny in either is final. Guardrail rules exist once, at the deployment, and a profile selects which detectors run rather than which rules apply. A team can be delegated its own access rules and cannot be delegated its own guardrail floor.
- A key naming a profile that no longer exists is refused. Deleting a profile does not promote its keys to unrestricted, which is the opposite of what deleting it meant.
- Break-glass is filesystem access, not a secret. The local admin socket has no issuer, no bearer token and no shared secret. The kernel’s peer credentials are the identity, so reaching it means reaching the host, and a secret that travels is never the thing being presented.
- A suspension is a subtraction, not a config write. Suspending a key, a provider, a rule or a principal takes effect without editing the document and expires on its own lease, so the break-glass path does not leave a permanent grant behind.
What it does not defend against
Section titled “What it does not defend against”Stated plainly, because a control that is believed to do more than it does is worse than no control.
- A determined exfiltrator. Pattern detection finds structured PII in the shapes it knows. It does not find paraphrase, base64, an unusual encoding, or a description of a secret. Treat guardrails as compliance and accident prevention, not as an adversary-resistant filter.
- Prompt injection and jailbreaks. Tier 0 does not cover them at
all. Two detectors do, and both reduce rather than prevent. The
classifydetector, see stopping prompt injection with a classifier, runs a checkpoint trained on the question and has no instructions to exploit. It catches the phrasings it was trained on and false-positives on prose about security. Thellmjudge, see judging with a model, takes a policy in prose, and reads exactly the text an attacker wrote. A model that follows instructions can be told what to answer. Treat either’s verdicts as a filter on the common case, run it in shadow until the false-positive rate has been read, and do not build a control on the assumption that it cannot be talked round. - Anything the gateway never sees. Traffic on the same provider key that does not traverse pistra is ungoverned. Reconciliation exists to detect this, not to prevent it.
- A compromised gateway host. Provider credentials are in memory. A host with an attacker on it is over.
- Whoever can write the configuration. Policy, roles and rules are
file-owned, so the caller who can change that file, or restart the
process against a different one, holds the highest privilege in the
deployment. A built-in super-admin was rejected for that reason
rather than omitted: it would have been a second escape beside one
that cannot be closed. Govern the file the way you govern the cluster,
and read
admin.authzrecords for who changed what. - Whoever holds the cluster secret. The raft transport and the forward listener the nodes use for writes, membership and restores run under mutual TLS, with a certificate authority every node derives from the cluster secret and a per-node certificate under it that is replaced daily. Nothing on the path between the nodes reads or injects there, and a peer without the secret is refused at the handshake. The other side of that: the secret is the whole identity. A holder of it mints a certificate for any node name, joins the cluster, and reads every replicated document that is not sealed.
- Whoever holds the unsealing material. The cluster secret, and any
kek_filewrapping a key, decrypt everything the barrier sealed. They are the deployment’s root secret and no policy in the gateway constrains them. - A stolen Kubernetes credential in the operator’s pod. The operator
and the InferencePool controller are the only components holding one.
The grant is
geton a Credential’s Secret in the namespaces being watched and neverlist, nopatch, nowatchand nodelete, and each writes a source of its own through the admin API. That bounds what the credential reaches rather than removing the exposure. - Model output correctness. Not a security control and not claimed as one.
- Bodies it could not read. Over
max_body_bytes, unparseable, or, on inspected traffic, a response whose content-type is neither JSON nor SSE. All counted inpistra_guardrail_uninspected_total{route,reason}rather than assumed clean. Alert on it. A body that was not examined is not a clean one. - Entities split across frames of an unknown streaming API. On a route the segmenter has no dialect for, the inspector reads each SSE frame on its own rather than assembling them into one logical message. A match spanning two frames there is missed. Assembling them would invent entities spanning two unrelated messages, which is the worse error.
Trust boundaries in configuration
Section titled “Trust boundaries in configuration”- Config is yours. Policy, rules, profiles,
require_caps, fidelity behaviour, guardrail actions, lives in your file and comes from your git repository. - Catalog overlays carry facts, not policy. They can describe
endpoints, dialects, model gates and capability claims inside the
vocabulary the binary already compiles. They cannot invent a
capability, a dialect, a translator pair, or an action. An overlay
naming one the binary does not have is refused at load, so the widest
thing an overlay can do is describe an endpoint wrongly.
GET /admin/v1/catalog/reportshows that with its provenance. - The inspector holds no routing authority. It runs on its own
listener with no code path to a backend decision, asserted by a test
across every outcome, request, response and stream, refused and
allowed. It emits no header mutation and never writes
:path. Header and query segments are read-only for that reason rather than merely unimplemented. A filter pointed at it cannot be tricked into producing a routing decision.
Failure behaviour
Section titled “Failure behaviour”- A detector that errors is skipped (
fail_open) or refuses the request with 503 (fail_closed). Choose per detector. There is no safe default in general. - A rule whose condition cannot be evaluated refuses the request
(
fail_closed, the default) or is skipped and logged (fail_open). Choose per rule. The default is closed because the two ways a condition fails, a key that is not there and a cost budget exceeded, are both reachable from the client side, on access rules at the MCP hop overargs, at the A2A hop overparts, and on guardrail rules over a body’s annotations. A deny rule that a large enough argument list could switch off is not a deny rule. Writehas()guards for keys that vary. - Every CEL condition runs under a per-evaluation cost budget (the Kubernetes admission-policy figure) and checks the request context as it iterates. A nested comprehension over client-sized input stops at the budget rather than at the end of the list, and a client that has gone away stops paying for its own policy.
- A config that fails to build is rejected whole. The previous snapshot keeps serving.
- A WebSocket frame decoder that desyncs disables metering rather than touching the session.
Reporting
Section titled “Reporting”SECURITY.md is the channel, the expected response and what a report should carry. Treat any finding in credential handling, key storage, the admin plane’s default-deny, or the inspector’s separation from routing as security-relevant.
The repository is pre-release, and interfaces may change without notice.