praxis_policy/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4//! **PPE is a policy enforcement runtime for AI agents.**
5//!
6//! It is a deterministic reference monitor between an agent and every
7//! capability it invokes: tools, prompts, resources, inference providers, and
8//! A2A methods. Each operation runs through a policy-defined pipeline that can
9//! resolve identity, make an authorization decision (delegated to an engine
10//! like Cedar or CEL), exchange and reduce credentials before a downstream
11//! call, redact inputs and outputs, track information flow across calls, and
12//! audit. You write that policy declaratively in APL, the configuration that
13//! defines each operation's pipeline; PPE evaluates and enforces it at the
14//! boundary, against state the model cannot observe or forge.
15//!
16//! - Source and issues: <https://github.com/praxis-proxy/policy>
17//!
18//! # This crate
19//!
20//! `praxis-policy` is the **host facade**: one dependency that re-exports the PPE
21//! runtime (`praxis-policy-core`, `praxis-policy-apl-core`, `praxis-policy-apl-cmf`, `praxis-policy-apl-runtime`), so a host depends
22//! on this crate instead of pinning each of them separately.
23//!
24//! By default it is the **engine only**: no builtin plugins are compiled in.
25//! The bundled plugins, PDPs and session stores are registered from here, each
26//! behind a feature, and only what you enable is compiled.
27//!
28//! # Usage
29//!
30//! Engine only (register your own factories):
31//!
32//! ```no_run
33//! use std::sync::Arc;
34//! use praxis_policy::PolicyEngine;
35//!
36//! let mgr = Arc::new(PolicyEngine::default());
37//! // ... register host factories, then `praxis_policy_apl_runtime::register_apl(&mgr, opts)`.
38//! ```
39//!
40//! With the bundled builtins (enable the `builtins` feature):
41//!
42//! ```ignore
43//! use std::sync::Arc;
44//! use praxis_policy::PolicyEngine;
45//!
46//! let mgr = Arc::new(PolicyEngine::default());
47//! // Register every enabled builtin factory and install the APL config
48//! // visitor (in-process defaults) in one call:
49//! praxis_policy::install_builtins(&mgr);
50//! // ... then load a config that references the enabled `kind`s.
51//! ```
52//!
53//! # Features
54//!
55//! No plugins are on by default (`praxis-policy` alone is the engine).
56//! `builtins` enables every bundled extension, including the Valkey session
57//! store; or pick a granular subset (`jwt`, `oauth`, `elicitation-ciba`,
58//! `cedar`, `cel`, `opa`, `valkey`). Any of them brings in the registration
59//! helpers, and each one re-exports its own concrete factory type here.
60//!
61//! # Plugins the host supplies
62//!
63//! A plugin does not have to be bundled. Implement [`PluginFactory`] and hand it
64//! to [`PolicyEngine::register_factory`] under the `kind:` your YAML names;
65//! [`prelude`] is the surface to write it against. An unrecognised `kind` is a
66//! load-time error, so a missing registration fails at startup rather than
67//! silently skipping the plugin.
68//!
69//! `reference/plugins/` in this repository holds two worked examples, a PII
70//! scanner and an audit logger. Neither is published or bundled; a host registers
71//! them.
72
73// Whole-crate re-exports for advanced use (types not surfaced below).
74
75pub use {
76 praxis_policy_apl_cmf, praxis_policy_apl_core, praxis_policy_apl_runtime, praxis_policy_core,
77};
78
79pub use praxis_policy_apl_core::step::PdpFactory;
80pub use praxis_policy_apl_runtime::{
81 AplOptions, DispatchCache, MemorySessionStore, SessionStore, SessionStoreFactory, register_apl,
82};
83pub use praxis_policy_core::engine::PolicyEngine;
84
85/// The two types a host needs to accept a plugin it did not compile in:
86/// [`PolicyEngine::register_factory`] takes a `Box<dyn PluginFactory>`, and
87/// [`PluginInstance`] is what that factory returns.
88///
89/// Surfaced here so a host embedding the engine can name them without reaching
90/// through to `praxis_policy_core`. Plugin *authors* get the same two names from
91/// [`prelude`].
92pub use praxis_policy_core::factory::{PluginFactory, PluginInstance};
93
94/// Curated re-exports for plugin authors, so a plugin crate can depend on this
95/// facade alone. See [`praxis_policy_core::prelude`].
96pub use praxis_policy_core::prelude;
97
98// Concrete factory types + KIND consts, each behind its feature.
99#[cfg(feature = "cedar")]
100pub use praxis_policy_pdp_cedar_direct::CedarDirectPdpFactory;
101#[cfg(feature = "cel")]
102pub use praxis_policy_pdp_cel::CelPdpFactory;
103#[cfg(feature = "opa")]
104pub use praxis_policy_pdp_opa::OpaPdpFactory;
105#[cfg(feature = "oauth")]
106pub use praxis_policy_plugin_delegator_oauth::{KIND as OAUTH_KIND, OAuthDelegatorFactory};
107#[cfg(feature = "elicitation-ciba")]
108pub use praxis_policy_plugin_elicitation_ciba::{CibaApproverFactory, KIND as CIBA_KIND};
109#[cfg(feature = "jwt")]
110pub use praxis_policy_plugin_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND};
111#[cfg(feature = "valkey")]
112pub use praxis_policy_session_valkey::{
113 KIND as VALKEY_KIND, ValkeyConfig, ValkeySessionStoreFactory,
114};
115
116// =============================================================================
117// Builtin registration
118// =============================================================================
119//
120// The feature list, the factory re-exports above, and the registration table
121// below all describe the same set, so they belong in one crate. Split across two,
122// each side needs its own umbrella feature forwarding to the other, and the two
123// can disagree: an umbrella that compiles every builtin in while exporting none
124// of their types still builds.
125
126/// Generate [`register_builtin_plugins`] from a feature to factory table. Each
127/// entry expands to a `#[cfg(feature = ...)]`-gated, **explicit**
128/// `register_factory(KIND, Box::new(Factory))` call keyed off the builtin
129/// crate's own `KIND` const.
130///
131/// Explicit calls (rather than `inventory` / `linkme` link-section registration)
132/// are deliberate: when this engine is linked into an FFI staticlib the linker
133/// garbage-collects
134/// sections nothing references, which would silently drop auto-registered
135/// plugins. Naming each factory here keeps its object code alive.
136#[cfg(feature = "_builtin")]
137macro_rules! register_builtins {
138 ( $( feature $feat:literal => $krate:ident :: $factory:ident ),* $(,)? ) => {
139 /// Register every enabled by-kind plugin factory on `mgr`: identity
140 /// (`jwt`), delegators (`oauth`), and elicitation approvers
141 /// (`elicitation-ciba`). Call before loading a config so the engine can
142 /// instantiate plugins whose YAML `kind:` matches.
143 ///
144 /// A host adds its own with [`PolicyEngine::register_factory`], after
145 /// this call so a host registration wins on a shared `kind`.
146 ///
147 /// PDP and session-store factories are wired through [`AplOptions`]
148 /// instead; see [`builtin_pdp_factories`] and
149 /// [`builtin_session_store_factories`], or use [`install_builtins`].
150 #[allow(unused_variables)]
151 pub fn register_builtin_plugins(mgr: &std::sync::Arc<PolicyEngine>) {
152 $(
153 #[cfg(feature = $feat)]
154 mgr.register_factory($krate::KIND, Box::new($krate::$factory));
155 )*
156 }
157 };
158}
159
160#[cfg(feature = "_builtin")]
161register_builtins! {
162 feature "jwt" => praxis_policy_plugin_identity_jwt::JwtIdentityFactory,
163 feature "oauth" => praxis_policy_plugin_delegator_oauth::OAuthDelegatorFactory,
164 feature "elicitation-ciba" => praxis_policy_plugin_elicitation_ciba::CibaApproverFactory,
165}
166
167/// The enabled PDP factories, ready to drop into
168/// [`AplOptions::pdp_factories`]. A route's `cedar:`, `cel:` or `opa:` step
169/// selects which one runs.
170// `vec![]` can't replace the conditional pushes: each element is
171// `#[cfg]`-gated on its feature, so the set is built incrementally.
172#[cfg(feature = "_builtin")]
173#[allow(unused_mut, clippy::vec_init_then_push)]
174pub fn builtin_pdp_factories() -> Vec<std::sync::Arc<dyn PdpFactory>> {
175 let mut factories: Vec<std::sync::Arc<dyn PdpFactory>> = Vec::new();
176 #[cfg(feature = "cedar")]
177 factories.push(std::sync::Arc::new(CedarDirectPdpFactory::new()));
178 #[cfg(feature = "cel")]
179 factories.push(std::sync::Arc::new(CelPdpFactory::new()));
180 #[cfg(feature = "opa")]
181 factories.push(std::sync::Arc::new(OpaPdpFactory::new()));
182 factories
183}
184
185/// The enabled session-store factories, ready to drop into
186/// [`AplOptions::session_store_factories`]. A `global.apl.session_store:
187/// { kind: ... }` config block selects one; absent that, the in-process
188/// [`MemorySessionStore`] default stays active.
189#[cfg(feature = "_builtin")]
190#[allow(unused_mut, clippy::vec_init_then_push)]
191pub fn builtin_session_store_factories() -> Vec<std::sync::Arc<dyn SessionStoreFactory>> {
192 let mut factories: Vec<std::sync::Arc<dyn SessionStoreFactory>> = Vec::new();
193 #[cfg(feature = "valkey")]
194 factories.push(std::sync::Arc::new(ValkeySessionStoreFactory::new()));
195 factories
196}
197
198/// Register every enabled plugin factory and install the APL config visitor on
199/// `mgr` with in-process defaults (a [`MemorySessionStore`] and the default
200/// baseline capabilities). The enabled PDP and session-store factories are wired
201/// in, so a later config load can reference any of them by `kind`.
202///
203/// This is the one-call path; reach for [`register_builtin_plugins`] and
204/// [`AplOptions`] directly when you need to customize capabilities or the
205/// default store.
206#[cfg(feature = "_builtin")]
207pub fn install_builtins(mgr: &std::sync::Arc<PolicyEngine>) {
208 register_builtin_plugins(mgr);
209
210 let mut opts = AplOptions::in_process();
211 opts.pdp_factories = builtin_pdp_factories();
212 opts.session_store_factories = builtin_session_store_factories();
213
214 let _visitor = register_apl(mgr, opts);
215}
216
217#[cfg(all(test, feature = "_builtin"))]
218mod tests {
219 use super::*;
220 use std::sync::Arc;
221
222 #[test]
223 fn install_builtins_runs_without_panic() {
224 let mgr = Arc::new(PolicyEngine::default());
225 install_builtins(&mgr);
226 }
227
228 #[test]
229 fn pdp_factories_track_enabled_features() {
230 let expected = usize::from(cfg!(feature = "cedar"))
231 + usize::from(cfg!(feature = "cel"))
232 + usize::from(cfg!(feature = "opa"));
233 assert_eq!(
234 builtin_pdp_factories().len(),
235 expected,
236 "one PDP factory per enabled feature",
237 );
238 }
239
240 #[test]
241 fn session_store_factories_track_enabled_features() {
242 let expected = usize::from(cfg!(feature = "valkey"));
243 assert_eq!(
244 builtin_session_store_factories().len(),
245 expected,
246 "one session-store factory per enabled feature",
247 );
248 }
249
250 /// Load a one-plugin config against a engine with the builtins installed,
251 /// and return the error text (empty string on success).
252 ///
253 /// Goes through `load_config_yaml` rather than inspecting the registry
254 /// because that is the path an operator hits: the question is whether their
255 /// YAML `kind:` resolves, not what the map contains.
256 fn load_error_for_kind(kind: &str) -> String {
257 let mgr = Arc::new(PolicyEngine::default());
258 install_builtins(&mgr);
259 let yaml =
260 format!("plugins:\n - name: probe\n kind: {kind}\n hooks: [identity.resolve]\n");
261 match mgr.load_config_yaml(&yaml) {
262 Ok(()) => String::new(),
263 Err(e) => format!("{e}"),
264 }
265 }
266
267 /// The by-kind plugin table had no test, unlike the PDP and session-store
268 /// lists. So a builtin could leave the umbrella, or quietly rejoin it, with
269 /// nothing failing. This pins the set.
270 ///
271 /// Each enabled builtin must resolve its `kind`. The probe config is
272 /// deliberately minimal, so most of these still fail on their own settings —
273 /// what matters is that they fail on settings rather than on a missing
274 /// factory, which is a different message and a different operator problem.
275 #[test]
276 fn every_enabled_builtin_resolves_its_kind() {
277 let expected = [
278 (cfg!(feature = "jwt"), "identity/jwt"),
279 (cfg!(feature = "oauth"), "delegator/oauth"),
280 (cfg!(feature = "elicitation-ciba"), "elicitation/ciba"),
281 ];
282 for (enabled, kind) in expected {
283 if !enabled {
284 continue;
285 }
286 let err = load_error_for_kind(kind);
287 assert!(
288 !err.contains("no factory registered"),
289 "{kind} is enabled, so its factory must be registered; got: {err}"
290 );
291 }
292 }
293
294 /// The PII scanner and audit logger are reference implementations now, so the
295 /// umbrella must not register them. A host supplies them instead.
296 ///
297 /// Asserted as the exact operator-visible failure, because that message is
298 /// what tells someone their config needs a host registration rather than a
299 /// different feature flag.
300 #[test]
301 fn the_reference_plugins_are_not_registered_by_the_umbrella() {
302 for kind in ["validator/pii-scan", "audit/logger"] {
303 let err = load_error_for_kind(kind);
304 assert!(
305 err.contains("no factory registered"),
306 "{kind} must not be bundled; got: {err}"
307 );
308 assert!(
309 err.contains(kind),
310 "the error must name the unresolved kind: {err}"
311 );
312 }
313 }
314}