policy

Embeds the Policy Engine to enforce identity checks and APL rules, exchange tokens, redact fields, track session taint, and emit audit events.
On this page

Embeds the Praxis Policy Engine in-process to enforce multi-source identity, APL route policy, RFC 8693 token exchange, field redaction, session taint, audit emission, and (under body_access: read_write) request / response body rewriting. Content scanning is a host plugin the engine dispatches, not a bundled one.

Requires Cargo feature: policy-engine.

Configuration Notes

Registered under the YAML filter name policy. The policy-engine cargo feature is on by default; --no-default-features leaves the filter out.

A single request can carry multiple identity sources — user JWT in Authorization, agent JWT in X-Agent-Token, workload JWT in X-Workload-Token, etc. Each registered identity plugin reads its own configured header and contributes to a typed Extensions context.

On the body phase, the filter consumes protocol classifier filter metadata (from the praxis-ai package) to dispatch the matching CMF hook chain. APL routes (declared in the policy document) gate the tool/prompt/resource call by role, attribute, or Cedar PDP decision. delegate(...) steps mint audience-scoped tokens (RFC 8693) that the allow path attaches as upstream headers.

Policies with llm: routes authorize the top-level request model through cmf.llm_input, without classifier metadata. Missing, unlisted, and ambiguous models fail closed by default.

OPA, CEL, and Cedar pre_invocation steps on llm: routes also read the parsed request body as llm.request (input.llm.request in OPA, context.llm.request in Cedar), so a rule can inspect tools, messages, or input. Only a request attributed to a model carries it: a JSON body with a usable top-level model, no JSON-RPC envelope, and no mcp.method metadata. Other requests carry none. CEL and Cedar deny when a rule reads it, and so does an OPA boolean or object decision, but an OPA deny set needs its own guard. On allow the upstream receives the original bytes. A body over llm.max_request_bytes receives HTTP 413 before any authorization rule runs. Policy judges the body as it reaches this filter, so order body-rewriting filters before policy. For rule syntax, engine types, and absent-value behavior, see Structured request input in the policy engine docs.

PPE 0.4.1 changes OPA and CEL args on tool: routes to native JSON. Before upgrading, review rules that read args: for example, a numeric ID in args.ids must be compared with 13, not "13". A schema-backed cedar-direct PDP must set structured_context: true or the policy fails to load. Its schema must also declare the optional args and llm context fields its actions can receive, or those requests are denied. See the PPE 0.4.1 changelog and args migration guidance.

The configured policy receives the full parsed inference request. Treat policy documents and custom plugins as trusted: a rule or an explicit outbound call can disclose fields it reads. Review policy authorship and outbound endpoints before enabling structured request input.

On a policy with llm: routes, any request body without mcp.method metadata that repeats a key within one JSON object, at any depth, including keys that differ only in case, receives HTTP 400 with violation code llm.duplicate_key before any authorization rule runs, since backends disagree on which copy wins. This covers bodies with no model and unclaimed JSON-RPC envelopes too. The response names neither the key nor any value. A body that is not valid JSON is not refused for being malformed: it carries no usable model, so it is handled like any other body without one.

A lone differently cased field such as Tools is not a duplicate. llm.request keeps the wire spelling, so a rule checking only tools will not see Tools. If an upstream accepts such aliases, cover them explicitly in policy or require canonical field spelling at its boundary.

The CMF prompt text that APL steps and scanners read is projected from system, Responses instructions, messages[].content, legacy prompt, and input. For Responses and embeddings input, only text counts: a string, string items, and input_text, text, output_text, reasoning_text, or summary_text parts. Message items contribute their content. Every other item type, including tool calls, tool outputs, and reasoning, contributes its arguments, input, output, text, content, and summary. File search result text, code interpreter code and output logs, and listed MCP tool description are also projected. Token-ID arrays, images, and files are skipped.

body_access: read_write enables the JSON-RPC re-serialization round-trip so APL field mutators (redact(), assign()) rewrite the upstream request body and the downstream response. It also enables cmf.llm_output for non-streaming inference responses. APL field mutators do not rewrite inference bodies.

body_access: read_write also enables response-phase tool:, prompt:, and resource: rules, including attribute-only post_invocation rules. Under read_only, these rules are skipped and a warning is emitted. A response-only route adds no request-phase route rule; identity checks and global policy still apply.

result.<field> projection for prompt: and resource: routes remains blocked on policy #75; host-side content projection is tracked in Praxis #1330. A result: field pipeline on either entity, including an inherited one, is rejected at load so its controls cannot be silently skipped.

Policies with MCP entity routes cannot declare authorization: on an http: route. Use global for shared authorization; route-scoped authentication: remains supported.

Response-body hooks run on a small dedicated runtime while the worker waits, for at most twice the engine’s per-plugin timeout (engine_settings.plugin_timeout, so 60 seconds by default). A hook that does not finish in time is aborted and the response fails under the filter’s failure_mode: closed truncates the response, open passes the body through unfiltered.

Outbound policy calls share the proxy’s sub-request limits and circuit breaker, use HTTP/1.1, and keep a separate 1 MiB response ceiling. Calls made by response-body hooks use their own pool on the dispatch runtime, with the same connection limit but no circuit breaker. TLS uses the platform trust store; cluster private CAs and client certificates do not apply. A private destination requires trusted_private_endpoints for a specific host, or allow_private_idp to relax every callout.

An endpoint URL may name an IP address over http, but not over https: an IP carries no SNI, and Pingora peers skip certificate verification entirely when SNI is empty. Use a hostname for https.

Praxis filter configs are flat: the filter’s typed fields sit directly under the - filter: entry alongside the structural keys (name, conditions), not nested under a config: wrapper. See examples/configs/security/policy.yaml for a runnable example.

The referenced YAML is the policy document — plugins, routes, and identity-source declarations. The filter loads it once at construction and rejects misconfigured policy at server startup (fail-fast rather than at first request).

Configuration

FieldTypeRequiredDescription
body_accessread_only | read_writenoBody-access tier. ReadOnly (default) lets APL inspect request and response bodies for routing / policy decisions but discards any mutations. ReadWrite enables the CMF → JSON-RPC re-serialization round-trip so APL field mutators (e.g. args.ssn: redact(!perm.view_ssn)) rewrite the upstream body and response. Pay the round-trip cost only when needed.
config_pathstringyesFilesystem path to the policy document.
init_timeout_secsintegernoMaximum time, in seconds, to wait for PolicyEngine::initialize at filter construction. Identity plugins fetch JWKS over HTTPS during init; a reachable-but-unresponsive identity provider would otherwise hang startup or hot-reload indefinitely. On expiry, filter construction returns an error and the server fails fast. 30s is generous for legitimate cold-cache JWKS fetches over the public internet, while short enough that misbehavior is noticed during the deploy.
max_buffer_bytesintegernoMaximum request or response body size in ReadWrite mode. Also used as the default inference request limit.
allow_private_idpboolnoPermit every policy endpoint a private or loopback address. By default, private DNS answers are skipped and calls with no public answer are rejected, so a private destination needs one of two opt-ins: trusted_private_endpoints to relax a specific host to the RFC 1918 and unique-local ranges, or this flag to relax every callout globally. Proxy upstreams use cluster trusted_private_endpoints or allow_private_upstreams.
trusted_private_endpointsstring[]noHosts the policy engine may reach at a private address. Narrower than allow_private_idp. Only a callout whose host matches an entry reaches a non-public address, and only the RFC 1918 and unique-local (fc00::/7) ranges an in-cluster endpoint resolves to. A listed host still cannot reach loopback, link-local (including cloud metadata), the unspecified address, or any other reserved range. Every unlisted callout stays public-only. Matched on the URL host, case-insensitive, port excluded. An IPv6 address is a bracketed literal such as [fc00::1]. Shared address space (100.64.0.0/10) is deliberately not relaxable, so a pinned endpoint on a cluster that assigns pod addresses there, such as EKS with the VPC CNI secondary-CIDR pattern, is not reachable by a pin. Relaxing that range would need a separate per-host opt-in, off by default.
require_protocol_metadataboolnoFail-closed policy gate for misconfigured chains. When true (default), on_request_body rejects any request that reaches it without mcp.method filter-metadata. The metadata is set by the protocol classifier filter (available in the praxis-ai package), so its absence means either (a) the protocol classifier filter is missing from the chain, or (b) it is ordered AFTER policy instead of before. Either is a misconfiguration that would silently bypass CMF/APL policy. Set to false only when intentionally fronting non-classified traffic through the policy filter for identity-only enforcement (legacy behavior). Only applies to policies with MCP entity routes. Inference routes use their own gates instead. JSON-RPC methods that legitimately carry no entity (e.g. tools/list, initialize, prompts/list) still pass; require_protocol_metadata only rejects when the metadata is missing entirely.
llmLlmOptionsnoInference authorization options.
llm.max_request_bytesintegernoMaximum buffered inference request size, in bytes. Requests over this limit receive HTTP 413.
llm.promote_paramsstring[]noTop-level scalar fields promoted to custom.llm.<name>. A configured list replaces the defaults.
llm.providerstringnoOperator-supplied provider recorded on llm.provider.
llm.require_modelboolnoDeny a request whose body carries no usable top-level model. Enabled by default. When disabled, the request falls through to other policy paths.
llm.require_routeboolnoDeny a model no llm: route selects. Enabled by default. Disable only to admit unlisted models.
context_headersobject<agent.conversation_id | agent.turn | agent.agent_id | agent.parent_agent_id | agent.input | framework.framework | framework.framework_version | framework.node_id | framework.graph_id, string>noMaps extension field names to HTTP header names for populating host-supplied agent.* and framework.* context slots. The accepted keys are agent.conversation_id, agent.turn, agent.agent_id, agent.parent_agent_id, agent.input, framework.framework, framework.framework_version, framework.node_id, and framework.graph_id. Only headers with a reserved prefix (x-praxis-*, x-ext-agent-*, or x-ext-protocol-*) are accepted — this guarantees the value was set by a trusted gateway filter, not spoofed by the client (Praxis ingress strips reserved-prefix headers from client requests with a 400 reject). agent.session_id keeps its dedicated X-Session-Id handling and must not appear in this map. An absent or empty header leaves the extension field unset. A slot with no populated fields is left None on Extensions.
context_metaContextMetaConfignoStatic operator-supplied metadata for the meta extension slot. These values are per-deployment (not per-request) and populate meta.tags, meta.scope, and meta.properties on every request processed by this filter instance. Important: the PPE uses meta.scope for route resolution. Setting it here can change which policy route a request resolves to, not just what the audit record says.
context_meta.tagsstring[]noOperational tags; the only source of meta.tags in rules. A route’s own meta.tags are not merged in, and these do not join groups: bundles.
context_meta.scopestringnoHost-defined grouping (virtual server ID, namespace, etc.). Caution: PPE scores route resolution on meta.scope, so setting it here changes which policy route a request resolves to, not just what the audit record says.
context_meta.propertiesobject<string, string>noArbitrary key-value metadata, available as meta.properties.<key> in APL predicates.

Example

filter: policy
config_path: /etc/praxis/policy.yaml
body_access: read_write       # optional; default read_only
require_protocol_metadata: true    # optional; default true
init_timeout_secs: 30         # optional; default 30
max_buffer_bytes: 10485760
llm:
  require_model: true
  require_route: true
  provider: openai