feat: add CPEX policy filter#615
Conversation
Introduce the CPEX security filter, a feature-gated (`cpex`) HTTP filter that brings policy-driven access control to agentic / MCP traffic. It composes with Praxis content filters: - multi-source identity resolution: distinct JWTs for user, agent, and workload, each validated against live JWKS and bound to its own header - authorization routing: attribute predicates (role, team, permission) with fast-path deny semantics - RFC 8693 OAuth 2.0 token exchange: per-audience delegated credentials minted at request time, so the upstream never sees the caller's original IdP token - on-the-wire request/response body rewriting (e.g. field redaction) under `body_access: read_write` Co-authored-by: Frederico Araujo <frederico.araujo@ibm.com> Signed-off-by: Teryl Taylor <terylt@ibm.com> Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Address three issues in the CPEX filter's response path: - Response framing desync: fit_to_original_length now truncates a rewritten body to the committed Content-Length instead of shipping a longer body. The downstream response length is already sent by the time on_response_body runs (praxis has no response-side equivalent of apply_mutated_content_length), so emitting more bytes let the overflow be parsed as the next response — a smuggling primitive. The response-rewrite path now fails closed with a length-fitted deny envelope (gateway.response_rewrite_overflow) rather than corrupting the body via truncation. - Policy bypass / data leak: build_response_content_for_method folds every text block into APL's view (not just the first), and reserialize_json_rpc_response_body collapses result.content to a single vetted text block on mutation, dropping all other blocks. APL's view and the emitted bytes are now the same content set, so no unvetted block survives a redaction. - Fail-open identity rebuild: a response-phase identity-rebuild failure now fails closed with a deny envelope (identity.post_phase_unavailable) instead of passing the upstream response through, symmetric with the request phase. Also normalize formatting across the cpex tree and fix two pre-existing lint failures surfaced under --features cpex: three-component semver for the apl-*/cpex-core deps, and a doc_lazy_continuation warning in the filter config. Tests: 42 cpex unit tests pass (incl. multi-block view, collapse-on-emit, truncate-on-grow, and deny-envelope sizing); make lint and clippy --features cpex -D warnings are clean. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Re-pin the cpex/apl dependencies to a rev that adds the CEL PDP backend,
the `run` alias for `plugin`, the `deny('reason', 'code')` action, and the
optional `apl:` wrapper, then wire the two new filter-side capabilities.
- Register the `apl-pdp-cel` PDP factory alongside `cedar-direct`, so a
route's `cel:` step is served by the same binary.
- Map the `X-Session-Id` request header to `agent.session_id` when building
the CMF extensions, so cpex subject-scopes session taint as
H(subject : session_id). `taint(label, session)` now persists across
requests and a later route can deny on `security.labels contains "label"`.
Add unit tests for a CEL route (allow and deny) and for session taint
(within-session deny, per-session isolation, cross-principal isolation),
and a README documenting the filter: configuration, the policy document,
the Cedar and CEL backends, identity, sessions and taint, the request and
response phases, and denials.
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Register the ValkeySessionStoreFactory so a `session_store: { kind:
valkey, ... }` block selects a Valkey-backed SessionStore for
distributed, restart-durable taint labels; the in-memory store stays
the default when no block is present.
Repin the cpex crates to ef0439e (flat `session_store` APL key) and add
the apl-session-valkey dependency under the `cpex` feature. Add a unit
test that builds the filter from a valkey `session_store` config; the
pool dials lazily, so no live Valkey is needed.
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Switch every cpex dependency from the ef0439e dev rev to the 0.2.0-alpha.3 git tag so the build tracks a named release instead of a floating commit. No code changes: the tag carries the flat `session_store` key, the apl-session-valkey crate, and a compatible AplOptions, and the filter builds and tests clean against it. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Collapse the eleven cpex dependencies (ten apl-* crates plus cpex-core) into one `cpex` facade dependency, pinned to an immutable rev and selecting plugins through its features (jwt, oauth, pii, audit, cedar, cel, valkey). Delete factories.rs: its register_builtin_factories and register_apl_visitor are exactly cpex::install_builtins, so the filter init is now a single call. Route the remaining cpex_core:: imports through the facade re-export. No behavior change; the demo passes end to end under both PDPs with taint persisting to Valkey. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Bump the cpex git dependency from 0c199c8 to the published 702ab163c64fe762318cb2bc5fb9b088869dd1af. Full 40-char SHA pins the exact commit; Cargo.lock updated for all cpex workspace crates. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
main now denies clippy::large_stack_frames (threshold 16384). Mark the two async CMF handlers that hold large cpex Extensions/Message types across await points with #[expect(...)], matching the pattern already used on other async filter handlers. Reorder the cpex CpexFilter re-exports in builtins/mod.rs and lib.rs to their alphabetical position so nightly rustfmt is clean. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The schema test all_example_configs_parse walks examples/configs/ and parses every YAML as a praxis Config. cpex-policy.yaml is a CPEX policy document (top-level `plugins:`), not a praxis config, so it failed with "unknown field `plugins`". Move it to tests/integration/fixtures/cpex-policy.yaml, outside the schema sweep. The example's config_path now points at an operator-supplied deployment path (the policy is operator-authored, not shipped here); the integration test rewrites that to the fixture so the filter still constructs against a real policy end to end. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
|
PR too large: 2998 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add |
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review
Summary: Adds a CPEX (Content Policy Enforcement Extension) policy filter that validates requests against a JWT-authenticated external policy service using JSON-RPC.
Overall: Substantial new filter with a well-structured config and policy evaluation flow. Key concerns are a fail-open on serialization failure, redundant header snapshots, JWT re-validation in response phase, and a git-rev dependency that may violate the cargo deny policy.
| Severity | Count |
|---|---|
| Critical | 1 |
| Large | 3 |
| Medium | 4 |
Findings
-
[Critical]
error.rs:120—unwrap_or_default()onserde_json::to_vecproduces an empty response body on serialization failure, which is a fail-open: the client receives a 200-class empty response instead of an error. Replace with explicit error propagation (return a 500 or a structured JSON error body) so serialization failures are never silent. -
[Large]
filter.rs:279—snapshot_headersis called 3 times per request-body phase. Snapshot the headers once at the start of the phase and thread the result through, rather than re-cloning the header map multiple times. -
[Large]
filter.rs:567— The response phase re-validates the JWT viabuild_cmf_extensions. If the token expires between the request and response phases (which can happen under slow upstreams), the response phase will issue a false deny on a request that was already authorized. Cache or reuse the validated token from the request phase. -
[Large]
Cargo.toml— Git-rev dependency (cpex-rs = { git = "...", rev = "..." }) may bypass the workspace'scargo denypolicy which setsunknown-git = "deny". Either add the git source toallow-gitindeny.toml, publish the crate, or vendor it. -
[Medium]
filter.rs:348—Ordering::Relaxedon the runtime-initialized check allows reads on other threads to see stale values. UseOrdering::Acquireon the load andOrdering::Releaseon the store. -
[Medium]
filter.rs:323—StreamBuffer { max_bytes: None }allows unbounded body buffering. A large request body can exhaust memory. Set a reasonable default max to prevent DoS via oversized payloads. -
[Medium]
json_rpc.rs:1— File-level lint suppression (#![allow(...)]) suppresses lints for the entire module. Prefer function-level#[allow(..., reason = "...")]on the specific items that need it, per project conventions. -
[Large] Integration test only covers 401 rejection, not the happy path. Project conventions require end-to-end functional proof that the feature works.
-
[Medium] Missing
#[cfg(feature = "cpex")]assertion inregistry.rsbuiltins_registeredtest.
Resolve findings from the PR praxis-proxy#615 bot review and clean up pre-existing cpex-module debt that CI misses because `make lint`, docs, and clippy never run with `--features cpex`. - error.rs: replace `unwrap_or_default()` on the deny envelope with a const valid-envelope fallback (the body is infallibly serializable; keeps the deny path total without an empty body or a panic). - filter.rs: snapshot request headers once in `build_cmf_extensions` instead of cloning the header map twice per phase. - filter.rs: use Acquire/Release ordering on the runtime-flavor check. - config.rs/filter.rs: add `max_buffer_bytes` (default 10 MiB) and bound the `read_write` StreamBuffer instead of buffering unboundedly. - json_rpc.rs: scope the lint suppression to the functions that trip it with function-level `#[expect]` instead of a module-level `#![allow]`. - registry.rs: assert the cpex filter registers under `cfg(cpex)`. - deny.toml: allow the cpex git source so `cargo deny check` passes. - integration: add a happy-path test that mints a valid HS256 JWT and asserts end-to-end pass-through to the backend. - tests/json_rpc/mod.rs: convert `#[allow]` to `#[expect]`, fix a redundant slice and an anonymous trait import, and repair two broken rustdoc intra-doc links so the module is clean under `--features cpex`. - docs: regenerate cpex.md and document `max_buffer_bytes`. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
|
Addressed praxis-bot reviews.
|
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
|
Some asynchronous conversations going on about this as well. Will try to provide a summary and updates on those talks here as we progress. |
quinn-proto < 0.11.15 is flagged by RUSTSEC-2026-0185 (remote memory exhaustion via unbounded out-of-order stream reassembly). It enters the lockfile only as reqwest's optional http3 dependency (reqwest is pulled by the cpex crates apl-delegator-oauth/apl-identity-jwt) and is never compiled, but cargo audit scans the whole lockfile. Bump the pin to the patched 0.11.15; no build impact. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
| bytes = "1.12.0" | ||
| chrono = { version = "0.4.45", default-features = false, features = ["clock"] } | ||
| dashmap = "6.2.1" | ||
| cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "702ab163c64fe762318cb2bc5fb9b088869dd1af", default-features = false, features = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] } |
There was a problem hiding this comment.
This git-rev pin is the core of the coupling concern.
As an external crate using export_filters!, this dependency would live in the CPEX filter crate's own Cargo.toml, not here.
| allow-git = [] | ||
| # CPEX policy-runtime crates (feature-gated behind `--features cpex`). | ||
| # Pinned by exact rev in Cargo.toml until the crates are published. | ||
| allow-git = ["https://github.com/contextforge-org/cpex"] |
There was a problem hiding this comment.
This exception exists solely because of the git-rev pin above. An external filter crate wouldn't require any changes to Praxis's deny.toml.
| // could legitimately stall. | ||
| let mgr_for_init = Arc::clone(&mgr); | ||
| let init_timeout = std::time::Duration::from_secs(cfg.init_timeout_secs); | ||
| let init: Result<(), String> = std::thread::spawn(move || -> Result<(), String> { |
There was a problem hiding this comment.
Impedance mismatch: sync filter factory vs async plugin init.
CPEX's PluginManager::initialize is async, but Praxis's filter factory signature is sync. The workaround is spawning a dedicated OS thread to build a throwaway single-threaded tokio runtime and block_on the init future. The comment explains this is needed because block_on would panic if a runtime is already attached (which is the case under #[tokio::test]).
This is correct but it's a signal: when integrating a library requires spawning an OS thread to build a temporary runtime to drive a future to completion because the host's lifecycle doesn't match, the library may be better off in a crate that owns its own lifecycle.
| /// First request saw a multi-thread runtime; subsequent requests skip the check. | ||
| const RUNTIME_OK: u8 = 1; | ||
| /// First request saw a current-thread runtime; all requests reject. | ||
| const RUNTIME_REJECTED: u8 = 2; |
There was a problem hiding this comment.
Impedance mismatch: sync response body vs async hooks.
CPEX hooks are async but on_response_body is sync (Pingora constraint). The filter works around this with block_in_place + Handle::current().block_on(), which requires a multi-threaded tokio runtime. This constant plus the check at lines 356–367 permanently reject work_stealing: false deployments.
This is a hard constraint that narrows Praxis's deployment flexibility for all users who enable the feature, driven by an architectural mismatch between CPEX and Pingora. In an external crate, this is a documented requirement of that crate rather than a constraint on Praxis's runtime model.
| // callback can't be awaited). We're on a tokio worker so | ||
| // `block_in_place` lets us drive the async CMF dispatch without | ||
| // stalling other tasks. | ||
| let extensions = match tokio::task::block_in_place(|| { |
There was a problem hiding this comment.
Impedance mismatch: identity re-resolution in response phase.
The JWT is re-validated here because CPEX's hook model doesn't carry request-phase identity state forward. If the token expires between request and response phases (slow upstream), this produces a false deny on a request that was already authorized and already processed by the upstream. The prior review flagged this, the filter treats it as "fail closed" and replaces the body with a deny envelope, but the upstream has already acted on the request.
An external crate could evolve its own solution (e.g., caching the resolved identity in filter state) without requiring changes to Praxis's filter context API.
| register_http(factories, "compression", CompressionFilter::from_config); | ||
| register_http(factories, "cors", CorsFilter::from_config); | ||
| #[cfg(feature = "cpex")] | ||
| register_http(factories, "cpex", crate::CpexFilter::from_config); |
There was a problem hiding this comment.
This #[cfg(feature = "cpex")] registration inside register_http_builtins is what the export_filters! macro replaces. An external crate using export_filters! { http "cpex" => CpexFilter::from_config } plus [package.metadata.praxis-filters] in its Cargo.toml would auto-register via the build.rs discovery system.
Summary
Adds a
cpexHTTP filter that embeds the CPEX policy runtime in-process. CPEX is ContextForge's core framework for policy enforcement and extensibility, so a native filter brings Praxis to parity on the enforcement layer rather than onlyproxying to it. Policies are declarative per-route chains that turn decisions into ordered effects: deny, redact, delegate, taint, or run a plugin, with an in-process PDP as one step in the chain.
The filter is feature-gated (
--features cpex) and runs the CPEX / APL (Authorization Policy Logic) runtime as linked Rust crates. No sidecar, no FFI.Why use this filter?
The filter lets operators define authorization flows declaratively and turn decisions into ordered effects: redact a field, delegate a token, taint a session, call a PDP or rules engine in process, write an audit record.
The motivation are authorization patterns that are difficult to express with stateless decision engines:
on_allow/on_denydrive concrete actions in a defined order.Closes #614 Part of epic #30
Changes
filter/src/builtins/http/security/cpex/): identity resolution, APL route evaluation, request and response body rewriting, plugin dispatch, and session taint, wired into both the request and response phases.apl-pdp-cedar-direct) and CEL (apl-pdp-cel), both registered. A route'scedar:orcel:step selects one.deny('reason', 'code'),redact/maskonargs.*andresult.*,delegate(...)(RFC 8693 token exchange),taint(label, session), andrun(name)(alias forplugin).X-Session-Idmaps toagent.session_id, subject-scoped asH(subject : session_id), so taint persists across requests and never crosses principals.CpexFilterConfig(config_path,body_access,require_mcp_metadata,init_timeout_secs); simplified APL with an optionalapl:wrapper.contextforge-org/cpexcrates.examples/configs/security/.Testing
cargo test --features cpex,make lint, andclippy --features cpex -D warningsare green.Notes
Related: #238 #564 (demo shows an end-to-end authorization flow using CEL as PDP/rules engine for authz decisions)