Skip to content

feat: add CPEX policy filter#615

Open
araujof wants to merge 16 commits into
praxis-proxy:mainfrom
araujof:feat/cpex
Open

feat: add CPEX policy filter#615
araujof wants to merge 16 commits into
praxis-proxy:mainfrom
araujof:feat/cpex

Conversation

@araujof

@araujof araujof commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds a cpex HTTP 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 only
proxying 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:

  • Cross-call session reasoning — an agent reads compensation data then sends an email; each call is fine alone, but a session label carried across calls blocks the exfiltration.
  • Post-result enforcement — evaluate the actual tool response, not just the pre-invoke request.
  • Ordered effectson_allow / on_deny drive concrete actions in a defined order.

Closes #614 Part of epic #30

Changes

  • Filter (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.
  • PDPs: in-process Cedar (apl-pdp-cedar-direct) and CEL (apl-pdp-cel), both registered. A route's cedar: or cel: step selects one.
  • Effects: deny('reason', 'code'), redact / mask on args.* and result.*, delegate(...) (RFC 8693 token exchange), taint(label, session), and run(name) (alias for plugin).
  • CMF bridge: translates Praxis filter context (JSON-RPC tool / prompt / resource, identity, headers) into CPEX Common Message Format plus typed extensions, the input schema policies evaluate against.
  • Session taint: X-Session-Id maps to agent.session_id, subject-scoped as H(subject : session_id), so taint persists across requests and never crosses principals.
  • Config: CpexFilterConfig (config_path, body_access, require_mcp_metadata, init_timeout_secs); simplified APL with an optional apl: wrapper.
  • Deps: pin the contextforge-org/cpex crates.
  • Docs: filter README and an example config under examples/configs/security/.

Testing

  • Unit tests for identity, CEL route allow and deny, session taint (within-session, per-session, cross-principal), and response-phase framing and redaction.
  • cargo test --features cpex, make lint, and clippy --features cpex -D warnings are green.
  • End-to-end demo in the praxis-demos repo: nine scenarios under both Cedar and CEL. All pass.

Notes

Related: #238 #564 (demo shows an end-to-end authorization flow using CEL as PDP/rules engine for authz decisions)

@araujof araujof added skip/pr-hygiene enhancement New feature or request labels Jun 17, 2026
terylt and others added 8 commits June 19, 2026 00:45
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>
araujof added 2 commits June 19, 2026 01:01
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>
@araujof araujof requested a review from shaneutt June 19, 2026 05:25
araujof added 2 commits June 19, 2026 01:35
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof araujof marked this pull request as ready for review June 19, 2026 05:55
@araujof araujof requested review from a team June 19, 2026 05:55
@praxis-bot-app

Copy link
Copy Markdown

PR too large: 2998 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add skip/pr-conventions label to override.

@shaneutt shaneutt self-assigned this Jun 19, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in AI Gateway Jun 19, 2026
@shaneutt shaneutt moved this from Backlog to Review in AI Gateway Jun 19, 2026
@shaneutt shaneutt added this to the v0.4.0 milestone Jun 19, 2026
@shaneutt shaneutt added skip/pr-conventions Skip conventions checks for PRs and removed skip/pr-hygiene labels Jun 19, 2026

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:120unwrap_or_default() on serde_json::to_vec produces 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:279snapshot_headers is 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 via build_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's cargo deny policy which sets unknown-git = "deny". Either add the git source to allow-git in deny.toml, publish the crate, or vendor it.

  • [Medium] filter.rs:348Ordering::Relaxed on the runtime-initialized check allows reads on other threads to see stale values. Use Ordering::Acquire on the load and Ordering::Release on the store.

  • [Medium] filter.rs:323StreamBuffer { 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 in registry.rs builtins_registered test.

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>
@araujof

araujof commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

Addressed praxis-bot reviews.

  • error.rs fail-open (Critical): not actually a fail-open. The body is an already-built json! value (serialization cannot fail in practice) and every caller is a deny path with status already committed. Hardened anyway to a const valid deny envelope, so it stays total and never panics.
  • Response-phase JWT re-validation: does not apply. The filter parses no JWTs. It re-resolves identity in-process over the frozen request headers and already fails closed. No expiry step, no false deny. Unchanged.
  • Header snapshots: build_cmf_extensions now snapshots once and reuses it.
  • cargo deny: added the cpex git source to allow-git.
  • Integration test: added a happy-path test that mints a valid HS256 JWT and asserts end-to-end pass-through (200).
  • Atomic ordering: Acquire on load, Release on stores.
  • Unbounded buffer: new max_buffer_bytes config (default 10 MiB), bounded the read_write StreamBuffer, with tests and docs.
  • File-level lint suppression: replaced the #![allow] in json_rpc.rs with function-level #[expect] (only too_many_lines fires).
  • Registry test: added the cfg(cpex) assertion.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof araujof requested a review from praxis-bot June 23, 2026 02:55
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@shaneutt

Copy link
Copy Markdown
Member

Some asynchronous conversations going on about this as well. Will try to provide a summary and updates on those talks here as we progress.

@shaneutt shaneutt added the holding-pattern Waiting for discussions or contributor updates in order to proceed label Jun 23, 2026
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>
Comment thread Cargo.toml
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"] }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread deny.toml
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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(|| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread filter/src/registry.rs
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@shaneutt shaneutt removed this from the v0.4.0 milestone Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request holding-pattern Waiting for discussions or contributor updates in order to proceed skip/pr-conventions Skip conventions checks for PRs

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

Spike: CPEX filter

5 participants