Skip to content

Threat model

What pistra defends against, what it does not, and which claims are checkable rather than promised.

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.

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

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.

  • 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 unless apply_to names it, because scanning them runs the ensemble over the request’s own authorization header. 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 addressable ref, so a.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.

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.

  • 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 forbid would be a forbid that 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.

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 classify detector, 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. The llm judge, 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.authz records 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_file wrapping 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 get on a Credential’s Secret in the namespaces being watched and never list, no patch, no watch and no delete, 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 in pistra_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.
  • 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/report shows 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.
  • 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 over args, at the A2A hop over parts, 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. Write has() 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.

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.