Configuration
On this page
A PPE configuration is one YAML document. It declares the plugins the process can reach, the cross-cutting wiring, and the policy that decides which of them run for a given operation.
Every key is known. A key the document model does not name fails the
load, and where a key was renamed the error names its replacement. If
your configuration loads, every key in it does something. That is a
deliberate reversal: keys used to be dropped silently, so a stale
plugin_settings: block could take every engine setting down with it
and leave the process running in a mode nobody chose.
The six top-level keys
engine_settings: # dispatch mode and runtime limits
global: # cross-cutting wiring, defaults, and policy
plugins: # the plugins available, by kind
groups: # reusable policy bundles routes opt into
routes: # policy, one entry per operation
secrets: # secret material: providers, and the values bound to them
Anything else is a load error. All six are optional.
Dispatch modes
engine_settings.dispatch picks between two mutually exclusive models,
and a document is legal in one of them only.
| Mode | What selects a plugin | Rejects |
|---|---|---|
policy (default) | a run(name) step, or a policy block that reaches one | a per-plugin conditions: and priority:, and a list-form plugins: |
hooks | each plugin’s own hooks: and conditions: | routes:, groups:, global:, global.defaults: |
Under dispatch: policy, loading fails if a declared plugin is unreachable
and warns if a plugin is reached on fewer hooks than it declares.
Audit sinks receive verdicts without policy steps and are
exempt from the reachability error unless mode: disabled. Unused hooks
declared by a sink still produce warnings.
| Setting | Default | Meaning |
|---|---|---|
dispatch | policy | which model above |
plugin_timeout | 30 | per-plugin timeout, in seconds |
audit_timeout_milliseconds | 100 | maximum time per audit sink callback, in milliseconds |
short_circuit_on_deny | true | stop a hook’s remaining plugins once one denies |
route_cache_max_entries | 10000 | dispatch-plan cache size |
What each scope accepts
The accept sets below are the config model’s own tables. A key outside its scope’s set is a load error naming the scope and the set.
| Scope | Keys |
|---|---|
| the document | global, plugins, groups, routes, secrets, engine_settings |
global: | defaults, authentication, assertions, response, authorization, pdp, session_store, attribute_files |
global.defaults.<entity>: | description, metadata, plugins, authentication, assertions, response, authorization, args, result |
groups.<name>: | the same set as global.defaults.<entity>: |
routes[] | tool, resource, prompt, llm, http, meta, groups, plugins, authentication, assertions, response, authorization, args, result |
engine_settings: | dispatch, plugin_timeout, audit_timeout_milliseconds, short_circuit_on_deny, route_cache_max_entries, effect_log_path, effect_log_compaction_threshold, capture_content_provenance, content_provenance_key, audit_stream_namespace |
an authentication: block | steps, replace_inherited |
an authentication: step | name, config |
an assertions: block | request, response |
an assertions: direction | headers, strip, replace_inherited |
an assertions: header entry | name, from, members, on_missing, encode |
Three scopes require additional constraints.
pdp:, session_store: and attribute_files: are global: only.
They wire process-global machinery, so there is nowhere else for them
to mean anything.
args: and result: are not accepted under global:. A field
pipeline names one field of a payload, and global: covers every entity
route at once rather than carrying a payload of its own. Write the
pipeline on each global.defaults.<entity>: block that has a payload,
or on the routes themselves. This is a removed capability rather than a
tightening: there is no longer any spelling for one field pipeline
covering every entity route.
plugins: changes shape by mode. Under dispatch: policy it is a map
of per-plugin overrides. The list form, which activated a chain, is a
load error; invoke a plugin with run(name) instead.
Plugins
Each entry declares how a plugin is identified, where it runs, and what it may see.
| Field | Meaning |
|---|---|
name | instance name, referenced from policy as run(name) or delegate(name, ...) |
kind | which implementation, for example identity/jwt |
hooks | the hook points it registers on |
mode | sequential (default), transform, audit, concurrent, fire_and_forget, or disabled; see Execution modes |
on_error | fail (default), ignore, or disable |
capabilities | declared context access, see Extensions |
config | plugin-specific settings |
plugins:
- name: jwt-user
kind: identity/jwt
hooks: [identity.resolve]
capabilities: [perform_http]
config:
role: user
header: X-User-Token
trusted_issuers:
- issuer: "https://idp.example.com/realms/agents"
audiences: ["ppe-gateway"]
decoding_key:
kind: jwks_url
url: "https://idp.example.com/realms/agents/protocol/openid-connect/certs"
A plugin that reaches outside the process must declare perform_http.
Withholding it stops the call rather than degrading it, because a
plugin that quietly skipped its IdP call would fail open.
Global
global:
pdp:
- kind: cedar-direct
policy_text: |
permit(principal, action == Action::"read", resource is Repo)
when { principal.roles.contains("security") };
session_store:
kind: valkey
endpoint: localhost:6379
attribute_files:
- attributes/tenants.yaml
pdp: is a sequence, one block per decision point. An entry may carry
an optional cache: block with positive ttl_seconds and
max_entries (keep the cap modest; removal is O(n)); omission
leaves evaluation uncached. See
PDP decision cache. session_store:
selects where Session Taint labels live; without it, labels stay in an
in-process memory store and do not survive a reload or reach a second
replica. attribute_files: loads the operator-maintained tree policy
reads under data.*, covered in
Static Attributes.
Routes
Routes carry the policy, one entry per operation, selected by tool:,
resource:, prompt:, llm:, or http:.
plugins:
- name: workday-oauth
kind: delegator/oauth
hooks: [token.delegate]
- name: audit-log
kind: audit/logger
hooks: [cmf.tool_pre_invoke]
routes:
- tool: get_compensation
authorization:
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, audience: workday-api)"
- "taint(secret, session)"
- "run(audit-log)"
result:
ssn: "str | redact(!perm.view_ssn)"
Both phase lists nest under authorization:. Writing pre_invocation:
or post_invocation: directly on a route is a load error, and an empty
authorization: block is rejected. args: and result: stay directly
on the route.
Groups
A group is a named, reusable bundle a route opts into: authentication:
steps, authorization: steps, field pipelines, or assertions. It is the
middle layer between global defaults and per-route policy, and
groups: is the only place a bundle is declared.
plugins:
- name: jwt-manager
kind: identity/jwt
hooks: [identity.resolve]
groups:
hr-tools:
authentication: [jwt-manager]
authorization:
pre_invocation:
- "require(role.hr)"
routes:
- tool: get_compensation
groups: hr-tools
groups: on a route takes a bare string or a list. It is sugar over
tags: meta: { tags: [hr-tools] } is exactly equivalent, and a
host-injected runtime tag joins a group the same way when its name
matches one.
Requests with no entity
A request carrying no entity metadata resolves no route. It is denied
with the violation code unidentified_request and a 400-class status,
kept distinct from a policy’s own deny because no rule was reached.
For authorizing plain HTTP requests rather than named entities, see HTTP Routing.
Secrets and key material
There is no ${ENV} substitution in configuration fields. A secret has
exactly one shape and the config names where it comes from rather than
carrying it.
The secrets: block
A provider reads one backend. A value binds a name to one
provider and one reference. Everything downstream names the value, never
the provider and never a raw reference, so the set of secrets the process
can reach is the values: map and nothing else.
secrets:
providers:
local: { kind: file, base_dir: /etc/ppe }
shell: { kind: env }
values:
upstream_api_key: { provider: local, ref: upstream.key }
session_password: { provider: shell, ref: VALKEY_PASSWORD }
Two provider kinds need no dependencies and ship in the engine. They are not
registered on their own: the host installs
SecretProviderRegistry::with_builtin_backends() with
PolicyEngine::set_secret_providers before initialize, and a document naming
a kind no registered factory provides fails there, naming the kind.
| kind | ref is | notes |
|---|---|---|
file | a path | With base_dir, a reference must be relative, may not contain .., and must resolve inside the directory once symlinks are followed. One trailing newline is stripped. An empty file is an error, not an empty value. |
env | a variable name | Takes no settings; any key is refused. A process’s environment is fixed at exec, so these never rotate without a restart. |
base_dir containment holds against the file a reference reaches, not just
against the reference as written, so a link below the directory cannot read
outside it. Links that stay inside are followed normally, which is what a
Kubernetes Secret volume and a CSI-projected secret both need: their ..data
indirection and the timestamped directory behind it are symlinks within the
mount. base_dir may itself be a symlink.
file covers Kubernetes Secret volumes, CSI-projected secrets, container
secret mounts, and a Vault Agent sidecar templating to disk, so a
deployment using any of those needs no network backend.
Vault KV v2
The vault kind is the secrets-vault feature, absent from the default
build. Compiling the feature does not register the factory:
install_builtins does not wire it. The host calls
register_vault_secret_provider (or registry_with_vault) with the same
HttpTransport it installed on the engine.
Every call goes through that transport. Auth is Kubernetes or AppRole,
with no default. The address must be https:// unless
insecure_http: true. The ref is <mount>/<path>#<field>, which is
GET /v1/<mount>/data/<path> and then that field of data.data.
The mount and path may contain spaces, #, or query-like characters; they
are percent-encoded as URL path segments. Empty segments and .. are refused.
The optional namespace is sent as X-Vault-Namespace on login, renewal, and
KV reads.
In-cluster Vault (Kubernetes service DNS, RFC 1918) is refused by the
bundled HyperTransport unless the host builds it with
with_allow_private_destinations. A proxy injecting its own transport
already chose an egress policy.
secrets:
providers:
vault-prod:
kind: vault
address: https://vault.example.com:8200
auth:
method: kubernetes
role: ppe
values:
session_password: { provider: vault-prod, ref: secret/ppe#password }
AppRole secret_id comes from an environment variable or a file. A
development-only literal is refused unless the document also sets
allow_insecure_literal: true. Kubernetes login rereads the projected
service-account token on every login.
Token renewal is lazy on the next get_secret (startup or
refresh_secrets()). Nothing spawns a ticker: a host that initializes
on a short-lived runtime would lose it. A renewable token is renewed
after two thirds of lease_duration, minus jitter; a non-renewable one
is obtained again. A 403 on a read triggers one reauthentication and
one retry, which is not a missing secret.
A name may contain letters, digits, _, -, and /. A / groups
names on a large document but a name is a key rather than a path, so a
leading, trailing, or repeated separator is refused: every name has one
spelling. A repeated key in providers: or values: is a load error
rather than a last-one-wins.
Resolution and refresh
Shape is checked at load: a value naming an undeclared provider fails against the document, without contacting a backend.
Every declared value is read during PolicyEngine::initialize(), before
any plugin initializes. A value that cannot be read stops startup. A
credential that has never resolved once has no last-good to serve, so
there is no degraded state to start in.
After startup the host decides when to re-read, by calling
refresh_secrets(). Nothing in the engine spawns a ticker: a task binds
to whichever runtime started it, and a host that initializes on a
short-lived runtime would lose it before it ticked once, leaving a
process that never rotates a credential and never says so.
A value that fails to re-read keeps its last-good bytes, so a backend outage after startup degrades to a possibly-stale credential rather than to none. The returned report names every failure. The interval a host picks is therefore the upper bound on how long a revoked credential stays in use.
PolicyEngine::secrets_last_success(provider) is the staleness signal to
alarm on: the gap between it and now is how long the credentials in memory
have gone unconfirmed against the backend. It is kept across a failure, so a
provider that has been failing for an hour still reports the success from
before it started failing.
PolicyEngine::secrets_health(provider) answers the other questions, as three
variants: Uninitialized (no store resolved, so nothing is being refreshed),
Undeclared (the document declares no such provider, so an alarm on that name
is watching nothing), or Resolved carrying the last success, the most recent
failure and its reason, and whether the most recent refresh failed. That last
flag is the fourth condition: it separates a provider failing right now from one
that is healthy. The last failure is kept after recovery, so a flapping provider
stays visible as one.
A consumer that has to act on rotation rather than just read the new bytes
reads SecretRef::snapshot(), which returns the value and its generation from
one load, and rebuilds whatever it built when a later snapshot reports a higher
generation. Reading get() and generation() separately can straddle a
refresh and pair old bytes with the new generation, and nothing moves the
generation again until the value changes once more.
Secrets and reload
Secrets are resolved once, during initialize(). A reload whose secrets:
block differs from the resolved one is refused, naming the declaration that
changed, and the running configuration and its credentials keep serving.
Adding, removing, or repointing a declaration therefore takes a restart.
Rotating the bytes behind an existing reference is unaffected: that is what
refresh_secrets() is for, and the reference does not change.
A host that reloads by constructing a new engine, loading the document into it,
and calling initialize() sees none of this, since nothing is resolved yet at
load time.
Per-plugin secret sources
Plugins that predate the secrets: block carry their own typed source
enums. OAuth and CIBA client secrets use client_secret_source:
client_secret_source:
kind: env_var
name: IDP_CLIENT_SECRET
Next
- HTTP Routing: select policy by request path and method.
- Identity and Delegation: configure inbound identity and outbound credentials.
- Header Assertions: project derived identity onto upstream requests.
- Upgrading APL: migrate an older configuration.