From 701bb47773aa3934c5aff9dfacba1342a8a87d91 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 12 Jun 2026 15:26:02 -0400 Subject: [PATCH 01/14] feat: add CPEX security filter for agentic identity and access control 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 Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo --- Cargo.lock | 957 ++++++++++++++- Cargo.toml | 12 + examples/README.md | 1 + examples/configs/security/cpex-policy.yaml | 31 + examples/configs/security/cpex.yaml | 77 ++ filter/Cargo.toml | 38 +- filter/src/builtins/http/mod.rs | 2 + filter/src/builtins/http/security/cpex/cmf.rs | 81 ++ .../src/builtins/http/security/cpex/config.rs | 113 ++ .../src/builtins/http/security/cpex/error.rs | 145 +++ .../builtins/http/security/cpex/factories.rs | 65 + .../src/builtins/http/security/cpex/filter.rs | 822 +++++++++++++ .../builtins/http/security/cpex/json_rpc.rs | 325 +++++ filter/src/builtins/http/security/cpex/mod.rs | 27 + .../src/builtins/http/security/cpex/tests.rs | 1089 +++++++++++++++++ filter/src/builtins/http/security/mod.rs | 7 +- filter/src/builtins/mod.rs | 2 + filter/src/lib.rs | 2 + filter/src/registry.rs | 2 + server/Cargo.toml | 1 + tests/integration/Cargo.toml | 1 + .../integration/tests/suite/examples/cpex.rs | 100 ++ tests/integration/tests/suite/examples/mod.rs | 2 + 23 files changed, 3884 insertions(+), 18 deletions(-) create mode 100644 examples/configs/security/cpex-policy.yaml create mode 100644 examples/configs/security/cpex.yaml create mode 100644 filter/src/builtins/http/security/cpex/cmf.rs create mode 100644 filter/src/builtins/http/security/cpex/config.rs create mode 100644 filter/src/builtins/http/security/cpex/error.rs create mode 100644 filter/src/builtins/http/security/cpex/factories.rs create mode 100644 filter/src/builtins/http/security/cpex/filter.rs create mode 100644 filter/src/builtins/http/security/cpex/json_rpc.rs create mode 100644 filter/src/builtins/http/security/cpex/mod.rs create mode 100644 filter/src/builtins/http/security/cpex/tests.rs create mode 100644 tests/integration/tests/suite/examples/cpex.rs diff --git a/Cargo.lock b/Cargo.lock index 69840ec4..a606c8d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,141 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apl-audit-logger" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-cmf" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "apl-core", + "cpex-core", + "serde_json", +] + +[[package]] +name = "apl-core" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "async-trait", + "cpex-orchestration", + "futures", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", +] + +[[package]] +name = "apl-cpex" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "apl-cmf", + "apl-core", + "async-trait", + "chrono", + "cpex-core", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "tracing", +] + +[[package]] +name = "apl-delegator-oauth" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "apl-core", + "async-trait", + "chrono", + "cpex-core", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "apl-identity-jwt" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "apl-core", + "async-trait", + "base64", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "apl-pdp-cedar-direct" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "apl-core", + "async-trait", + "cedar-policy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-pii-scanner" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -147,12 +282,27 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -347,6 +497,21 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -380,6 +545,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "brotli" version = "3.5.0" @@ -401,6 +576,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -480,6 +664,69 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "716a5103f447735b4cef15df847dc2a7ed8752e8a95febab5a6bfe1812054e43" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.14.0", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.18", +] + +[[package]] +name = "cedar-policy-core" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7935861e764efcba26f73ac4d9d284e30b2b4a9a95d3c961e908e3ed7889e391" +dependencies = [ + "chrono", + "educe", + "either", + "itertools 0.14.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.18", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca436a4803977e69b12a8e0e7f5d7c648e578b59fa0f2ed3bbe3c0054b9e036" +dependencies = [ + "cedar-policy-core", + "itertools 0.14.0", + "logos 0.16.1", + "miette", + "pretty", + "regex", + "smol_str", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -499,8 +746,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -631,6 +880,38 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpex-core" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "cpex-orchestration", + "futures", + "hashbrown 0.15.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "zeroize", +] + +[[package]] +name = "cpex-orchestration" +version = "0.1.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +dependencies = [ + "futures", + "tokio", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -768,6 +1049,40 @@ dependencies = [ "petgraph 0.7.1", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -834,6 +1149,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derivative" @@ -880,6 +1198,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "either" version = "1.16.0" @@ -889,6 +1225,35 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1174,8 +1539,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1185,9 +1552,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1409,6 +1778,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1428,13 +1813,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1548,6 +1936,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1577,6 +1971,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1587,6 +1982,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1618,6 +2015,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1669,6 +2072,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -1689,6 +2116,38 @@ dependencies = [ "libc", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph 0.7.1", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1751,6 +2210,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1866,6 +2343,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1956,7 +2439,8 @@ checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", "miette-derive", - "unicode-width", + "serde", + "unicode-width 0.1.14", ] [[package]] @@ -2010,6 +2494,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" @@ -2039,6 +2529,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "notify" version = "8.2.0" @@ -2119,6 +2618,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2261,6 +2769,21 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -2418,10 +2941,20 @@ dependencies = [ name = "praxis-proxy-filter" version = "0.3.1" dependencies = [ + "apl-audit-logger", + "apl-core", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pii-scanner", "async-trait", "bytes", + "chrono", + "cpex-core", "dashmap 6.2.1", "http", + "jsonwebtoken", "percent-encoding", "praxis-proxy-core", "praxis-proxy-proto", @@ -2432,6 +2965,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tonic", @@ -2604,6 +3138,23 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2775,6 +3326,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -2787,27 +3348,82 @@ dependencies = [ ] [[package]] -name = "pulldown-cmark-to-cmark" -version = "22.0.0" +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "pulldown-cmark", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "quanta" -version = "0.12.6" +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "crossbeam-utils", + "cfg_aliases", "libc", "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", + "socket2", + "tracing", + "windows-sys 0.60.2", ] [[package]] @@ -2938,7 +3554,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec55d3bcbd56e52b3424bc62739a237d43c14df8ed04aaf294ae187b60da348a" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "hashbrown 0.17.1", "parking_lot", "rand 0.8.6", @@ -3186,6 +3802,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.12.4" @@ -3215,6 +3851,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "ring" version = "0.17.14" @@ -3254,10 +3928,22 @@ version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "num-traits", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3333,6 +4019,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -3378,6 +4065,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3475,6 +4186,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3494,6 +4206,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3540,6 +4284,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3571,6 +4325,24 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -3592,6 +4364,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.4" @@ -3763,12 +4545,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3841,6 +4648,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3866,6 +4676,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4018,6 +4837,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4089,6 +4909,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4180,6 +5001,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4289,6 +5128,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4358,12 +5203,40 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4400,6 +5273,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4471,6 +5356,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.125" @@ -4513,6 +5408,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4541,6 +5446,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "winapi" version = "0.3.9" @@ -4955,7 +5866,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] @@ -5028,6 +5939,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 73356875..8e30d637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,17 @@ license = "MIT" repository = "https://github.com/praxis-proxy/praxis" [workspace.dependencies] +# CPEX runtime + APL plugins, all pinned to v0.2.0-ffi.test.7. Version +# fields and rev bump together when contextforge-org cuts the v0.2.0 +# release. +apl-audit-logger = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-cmf = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-core = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-cpex = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-delegator-oauth = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-identity-jwt = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-pdp-cedar-direct = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-pii-scanner = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } @@ -41,6 +52,7 @@ bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } dashmap = "6.2.1" clap = { version = "4.6.1", features = ["derive"] } +cpex-core = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } criterion = { version = "0.8.2", features = ["async_tokio"] } futures = "0.3.32" h2 = "0.4.15" diff --git a/examples/README.md b/examples/README.md index cfa8e2c3..f0691be6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,6 +129,7 @@ page. | File | Description | | ------ | ------------- | | [cors.yaml](configs/security/cors.yaml) | Spec-compliant CORS filter with preflight handling, origin validation, and credential support | +| [cpex.yaml](configs/security/cpex.yaml) | CPEX policy filter — multi-source JWT identity, APL routes, RFC 8693 delegation, PII scanning, audit, body rewriting (requires `--features cpex`) | | [csrf.yaml](configs/security/csrf.yaml) | Cross-site request forgery protection via origin validation | | [downstream-read-timeout.yaml](configs/security/downstream-read-timeout.yaml) | Protects against slow client attacks by limiting how long the proxy waits for data from downstream clients | | [forwarded-headers.yaml](configs/security/forwarded-headers.yaml) | Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host into upstream requests | diff --git a/examples/configs/security/cpex-policy.yaml b/examples/configs/security/cpex-policy.yaml new file mode 100644 index 00000000..be938be7 --- /dev/null +++ b/examples/configs/security/cpex-policy.yaml @@ -0,0 +1,31 @@ +# CPEX policy document for the `cpex.yaml` praxis example. +# +# Minimal shape: one HS256 JWT identity plugin, no routes. The cpex +# filter resolves identity in on_request (deny on missing/invalid +# JWT); on_request_body sees no APL route annotations, so policy +# dispatch is a no-op and the request passes through. +# +# This is enough to exercise the integration's wiring without +# requiring a Keycloak / JWKS endpoint to run the example. Production +# deployments swap HS256 for RS256+JWKS, add routes, attach +# delegators, and so on. See the CPEX demo in the praxis-demos +# repository (`demos/cpex/cpex.yaml`) for a fully-featured policy. + +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "https://idp.example.com" + audiences: ["praxis-cpex-example"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "REPLACE-WITH-A-PROPERLY-RANDOM-SHARED-SECRET-DO-NOT-COMMIT" + leeway_seconds: 60 + claim_mapper: standard diff --git a/examples/configs/security/cpex.yaml b/examples/configs/security/cpex.yaml new file mode 100644 index 00000000..dc9be6ab --- /dev/null +++ b/examples/configs/security/cpex.yaml @@ -0,0 +1,77 @@ +# CPEX Security Filter +# +# Embeds the CPEX policy runtime in-process to enforce multi-source +# JWT identity, APL route policy, RFC 8693 OAuth 2.0 token exchange, +# PII scanning, audit emission, and (under `body_access: read_write`) +# request / response body rewriting. +# +# The cpex filter consumes `mcp.method` / `mcp.name` metadata stashed +# by praxis's built-in `mcp` filter, which MUST be ordered before it +# in the chain. +# +# `config_path` points at the CPEX policy YAML — plugins + routes — +# which the filter loads once at construction. The example policy file +# at `cpex-policy.yaml` declares a single HS256 identity plugin and +# nothing else, so the filter accepts any valid bearer JWT and passes +# the request through to the upstream. Real deployments add routes, +# Cedar PDPs, and delegators in the policy file. +# +# `require_mcp_metadata: true` (the default) fail-closes when the +# `mcp` filter is missing or ordered after `cpex` — this guards +# against a misconfigured chain silently bypassing policy. +# +# This example builds only when the `cpex` cargo feature is enabled: +# +# cargo run --features cpex -p praxis -- \ +# -c examples/configs/security/cpex.yaml +# +# Exercise (assumes the backend at :3000 echoes 200): +# +# # Missing Authorization → 401 with WWW-Authenticate and +# # X-Cpex-Violation header: +# curl -i -X POST http://localhost:8080/mcp \ +# -H "Content-Type: application/json" \ +# -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", +# "params":{"name":"echo","arguments":{}}}' +# +# # Token rejected by HS256 signature mismatch → 401: +# curl -i -X POST http://localhost:8080/mcp \ +# -H "Authorization: Bearer bogus.token.bytes" \ +# -H "Content-Type: application/json" \ +# -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", +# "params":{"name":"echo","arguments":{}}}' +# +# The companion `cpex-policy.yaml` ships with a placeholder shared +# secret — replace it before doing anything beyond local +# experimentation. + +listeners: + - name: default + address: "127.0.0.1:8080" + filter_chains: + - main + +filter_chains: + - name: main + filters: + - filter: mcp + # Default mode parses MCP JSON-RPC bodies and stashes + # mcp.method / mcp.name in filter_metadata. Must precede cpex. + + - filter: cpex + config_path: examples/configs/security/cpex-policy.yaml + # Fail-closed when mcp.method is missing. Set to `false` only + # when intentionally fronting non-MCP traffic through cpex for + # identity-only enforcement. + require_mcp_metadata: true + + - filter: router + routes: + - path_prefix: "/" + cluster: backend + + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:3000" diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 489c7366..1117f0fc 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -17,6 +17,21 @@ name = "praxis_filter" default = ["ai-inference"] ai-inference = ["dep:secrecy", "dep:sqlx", "dep:tokio"] ext-proc = ["dep:praxis-proto", "dep:tonic", "dep:prost-wkt-types"] +# CPEX policy filter — multi-source identity, APL routes, delegation, +# PII detection, audit, and body rewriting. The CPEX runtime + the APL +# plugin set come in via the optional `apl-*` and `cpex-core` deps; +# `tokio` drives plugin init. +cpex = [ + "dep:apl-audit-logger", + "dep:apl-core", + "dep:apl-cpex", + "dep:apl-delegator-oauth", + "dep:apl-identity-jwt", + "dep:apl-pdp-cedar-direct", + "dep:apl-pii-scanner", + "dep:cpex-core", + "dep:tokio", +] [package.metadata.cargo-machete] ignored = ["praxis-proto", "prost-wkt-types", "tonic"] @@ -25,8 +40,16 @@ ignored = ["praxis-proto", "prost-wkt-types", "tonic"] workspace = true [dependencies] +apl-audit-logger = { workspace = true, optional = true } +apl-core = { workspace = true, optional = true } +apl-cpex = { workspace = true, optional = true } +apl-delegator-oauth = { workspace = true, optional = true } +apl-identity-jwt = { workspace = true, optional = true } +apl-pdp-cedar-direct = { workspace = true, optional = true } +apl-pii-scanner = { workspace = true, optional = true } async-trait = { workspace = true } bytes = { workspace = true } +cpex-core = { workspace = true, optional = true } dashmap = { workspace = true } http = { workspace = true } percent-encoding = { workspace = true } @@ -41,10 +64,23 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } sqlx = { workspace = true, optional = true } thiserror = { workspace = true } -tokio = { workspace = true, optional = true } +# Optional, shared by `ai-inference` and `cpex`. The `cpex` feature +# drives plugin `initialize()` at filter construction (the praxis +# filter-factory signature is sync, so we block on a single-thread +# runtime); the time/net features let JWT identity plugins fetch JWKS +# at init time. +tokio = { workspace = true, optional = true, features = ["rt", "time", "net"] } tonic = { workspace = true, optional = true } tracing = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +# `--features cpex` tests mint HS256 JWTs, write transient policy YAML +# files, and construct `RawDelegatedToken` instances directly (which +# requires a `chrono::DateTime` for `expires_at`). Always-on in +# test builds (overhead is small) so the `cpex` feature does not need +# to gate dev-deps. +chrono = { workspace = true } +jsonwebtoken = "9" +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/filter/src/builtins/http/mod.rs b/filter/src/builtins/http/mod.rs index a4a24163..24d19c89 100644 --- a/filter/src/builtins/http/mod.rs +++ b/filter/src/builtins/http/mod.rs @@ -38,6 +38,8 @@ pub use ai::token_usage::{TokenUsage, TokenUsageProvider, extract_token_usage}; pub use ai::{A2aFilter, JsonRpcFilter, McpFilter, TokenUsageHeadersFilter}; pub use observability::{AccessLogFilter, RequestIdFilter}; pub use payload_processing::{CompressionFilter, JsonBodyFieldFilter}; +#[cfg(feature = "cpex")] +pub use security::CpexFilter; pub use security::{ ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, ForwardedHeadersFilter, GuardrailsAction, GuardrailsFilter, IpAclFilter, PiiKind, RuleTargetKind, diff --git a/filter/src/builtins/http/security/cpex/cmf.rs b/filter/src/builtins/http/security/cpex/cmf.rs new file mode 100644 index 00000000..7591b09e --- /dev/null +++ b/filter/src/builtins/http/security/cpex/cmf.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! MCP method → CMF (Common Message Format) entity-coords mapping. +//! +//! Praxis's built-in `mcp` filter parses MCP JSON-RPC bodies and +//! stashes `mcp.method` / `mcp.name` in `ctx.filter_metadata`. This +//! module consumes that metadata and resolves the matching CMF +//! hook name + entity-type discriminator so APL routes annotated to +//! `tools/`, `prompts/`, or `resources/` get evaluated +//! against the right pre/post hook. +//! +//! # Scope of CMF dispatch +//! +//! CMF dispatch is intentionally narrow. Only the three MCP methods +//! that carry a routable entity participate: +//! +//! | MCP method | Entity type | Pre-hook | Post-hook | +//! |-------------------|-------------|----------------------------|-----------------------------| +//! | `tools/call` | tool | `cmf.tool.pre_invoke` | `cmf.tool.post_invoke` | +//! | `prompts/get` | prompt | `cmf.prompt.pre_invoke` | `cmf.prompt.post_invoke` | +//! | `resources/read` | resource | `cmf.resource.pre_fetch` | `cmf.resource.post_fetch` | +//! +//! Every other MCP method (`initialize`, `tools/list`, `prompts/list`, +//! `resources/list`, `resources/subscribe`, `ping`, `notifications/*`, +//! `roots/*`, `sampling/*`, plus anything an MCP extension adds) is +//! **identity-only by design**: the `on_request` identity gate still +//! runs, but `on_request_body` returns `BodyDone` without dispatching +//! CMF. The premise is that APL `route:` policy applies to entity +//! invocations, not to discovery / control-plane traffic. Operators +//! who need policy on a list operation can still gate it via praxis +//! filter `conditions:` on path / method. +//! +//! Adding a new entity-bearing MCP method here is a deliberate choice +//! (route annotations, hook constants, and identity-stripping +//! semantics all need to line up). The two `entity_for_mcp_method*` +//! functions are the closed switch — anything not listed falls +//! through to the identity-only path. + +use cpex_core::cmf::constants::{ + ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_PROMPT_POST_INVOKE, + HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, + HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, +}; + +// ----------------------------------------------------------------------------- +// Pre-phase +// ----------------------------------------------------------------------------- + +/// Map an MCP method to `(entity_type, pre_hook_name)` for the +/// request-phase CMF dispatch. Returns `None` for methods that don't +/// carry an entity (`tools/list`, `initialize`, `prompts/list`, etc.) — +/// in those cases identity still runs but CMF dispatch is skipped. +pub(super) fn entity_for_mcp_method(method: &str) -> Option<(&'static str, &'static str)> { + match method { + "tools/call" => Some((ENTITY_TOOL, HOOK_CMF_TOOL_PRE_INVOKE)), + "prompts/get" => Some((ENTITY_PROMPT, HOOK_CMF_PROMPT_PRE_INVOKE)), + "resources/read" => Some((ENTITY_RESOURCE, HOOK_CMF_RESOURCE_PRE_FETCH)), + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Post-phase +// ----------------------------------------------------------------------------- + +/// Post-phase mirror of [`entity_for_mcp_method`]. Maps the same +/// methods to the CMF `*_post_invoke` / `*_post_fetch` hook names so +/// `on_response_body` can dispatch APL `result:` pipelines. +/// +/// The method is read from `ctx.filter_metadata` — praxis's `mcp` +/// filter stashes it during the request phase and it persists across +/// the request/response lifecycle in the same context object. +pub(super) fn entity_for_mcp_method_post(method: &str) -> Option<(&'static str, &'static str)> { + match method { + "tools/call" => Some((ENTITY_TOOL, HOOK_CMF_TOOL_POST_INVOKE)), + "prompts/get" => Some((ENTITY_PROMPT, HOOK_CMF_PROMPT_POST_INVOKE)), + "resources/read" => Some((ENTITY_RESOURCE, HOOK_CMF_RESOURCE_POST_FETCH)), + _ => None, + } +} diff --git a/filter/src/builtins/http/security/cpex/config.rs b/filter/src/builtins/http/security/cpex/config.rs new file mode 100644 index 00000000..adc4473f --- /dev/null +++ b/filter/src/builtins/http/security/cpex/config.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Deserialized YAML configuration for the CPEX security filter. + +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// CpexFilterConfig +// ----------------------------------------------------------------------------- + +/// Configuration block for a `cpex` filter slot in a Praxis filter chain. +/// +/// 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/cpex.yaml` for a runnable example. +/// +/// ```yaml +/// filters: +/// - filter: cpex +/// config_path: /etc/praxis/cpex.yaml +/// body_access: read_write # optional; default read_only +/// require_mcp_metadata: true +/// ``` +/// +/// The referenced YAML is the CPEX 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). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpexFilterConfig { + /// Filesystem path to the CPEX YAML policy document. + pub config_path: String, + + /// Body-access tier. `ReadOnly` (default) lets APL inspect request + /// + 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. + #[serde(default)] + pub body_access: BodyAccessMode, + + /// Fail-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 praxis's built-in `mcp` filter, so its absence means either + /// (a) the `mcp` filter is missing from the chain, or (b) it is + /// ordered AFTER `cpex` instead of before. Either is a + /// misconfiguration that would silently bypass CMF/APL policy. + /// + /// Set to `false` only when intentionally fronting non-MCP + /// traffic through `cpex` for identity-only enforcement (legacy + /// behavior). + /// + /// Note: MCP methods that legitimately carry no entity (e.g. + /// `tools/list`, `initialize`, `prompts/list`) still pass — + /// `require_mcp_metadata` only rejects when the metadata is + /// missing entirely. + #[serde(default = "default_true")] + pub require_mcp_metadata: bool, + + /// Maximum time, in seconds, to wait for `PluginManager::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. + #[serde(default = "default_init_timeout_secs")] + pub init_timeout_secs: u64, +} + +/// `#[serde(default = ...)]` requires a free function for primitives +/// without a `Default` impl that returns the desired value. `true` is +/// the safer default for `require_mcp_metadata`. +fn default_true() -> bool { + true +} + +/// Default upper bound on `PluginManager::initialize` (seconds). +fn default_init_timeout_secs() -> u64 { + 30 +} + +/// What APL field-pipeline mutators on `args.` and +/// `result.` are allowed to do to the upstream body and +/// downstream response. +/// +/// Mirrors `praxis_filter::BodyAccess` but lifts the decision to +/// operator configuration: the choice changes pipeline behavior (and +/// cost), so a per-filter knob is the right granularity. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BodyAccessMode { + /// Body is buffered for inspection / routing; mutations are + /// discarded. APL `require()` predicates over body content + /// (`args.amount > 1000`) work; `redact()` / `assign()` are + /// silently dropped at the executor's write boundary. + #[default] + ReadOnly, + + /// Body is buffered + APL mutations to `args.*` and `result.*` are + /// re-serialized back into the JSON-RPC body so the upstream and + /// the downstream client see them. Costs one JSON parse + + /// serialize per mutated request or response. + ReadWrite, +} diff --git a/filter/src/builtins/http/security/cpex/error.rs b/filter/src/builtins/http/security/cpex/error.rs new file mode 100644 index 00000000..a3ef4a56 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/error.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Maps CPEX `PluginViolation`s to praxis `Rejection`s. + +use bytes::Bytes; +use cpex_core::error::PluginViolation; + +use crate::Rejection; + +// ----------------------------------------------------------------------------- +// auth_rejection (transport-level deny — HTTP 401) +// ----------------------------------------------------------------------------- + +/// JSON-RPC error code for gateway-side denials. Lives in the +/// implementation-defined `-32000` to `-32099` range carved out by the +/// JSON-RPC 2.0 spec for server errors. One code covers all of +/// `apl.policy`, `cedar.*`, `pii.*`, `delegation.*`, etc. — the +/// specific violation goes in `data.violation` so MCP clients can +/// switch on a single code while still seeing the underlying reason. +const MCP_GATEWAY_DENIED_CODE: i64 = -32001; + +/// **Public response-header contract.** Echoes the originating +/// `PluginViolation.code` (e.g. `auth.invalid_token`, `apl.policy`, +/// `pii.detected`) on every CPEX-emitted rejection so audit pipelines, +/// access logs, and downstream proxies can classify denials without +/// parsing the body. Sent on: +/// +/// * HTTP 401 ([`auth_rejection`]) — identity / transport-level deny. +/// * HTTP 200 ([`mcp_error_rejection`]) — application-level deny +/// wrapped in a JSON-RPC error envelope. +/// * HTTP 500 ([`super::filter::missing_mcp_metadata_rejection`]) — +/// `mcp.method` missing from filter metadata. +/// +/// Operators consuming this in audit / SIEM pipelines should treat the +/// header value as a stable identifier (the code namespace is part of +/// the API contract). The codes themselves are minor information +/// disclosure — they name the rule that fired but never carry user +/// data or claims; acceptable on the deny path. +pub(super) const VIOLATION_HEADER: &str = "X-Cpex-Violation"; + +/// Build an HTTP 401 rejection for transport-level authentication +/// failures (missing / invalid / wrong-audience JWT). Per the MCP +/// Authorization spec, identity failures are reported as HTTP 401 +/// with a `WWW-Authenticate` header — clients are expected to react +/// to the status + header, not parse the body. The body is included +/// only as a short human-readable diagnostic. +/// +/// The violation's `code` is also surfaced via the +/// [`VIOLATION_HEADER`] response header so middleware (audit, logging, +/// downstream proxies) can classify denials without parsing the body. +/// +/// TODO: once the gateway exposes its own `OAuth` Protected Resource +/// Metadata document, the `WWW-Authenticate` header should point at +/// it per RFC 9728 (`Bearer resource_metadata="..."`). Today we send +/// the minimum-compliant header. +pub(super) fn auth_rejection(violation: Option<&PluginViolation>) -> Rejection { + let (code, reason) = match violation { + Some(v) => (v.code.clone(), v.reason.clone()), + None => ( + "auth.unknown".to_owned(), + "authentication required".to_owned(), + ), + }; + let body = format!("{code}: {reason}"); + Rejection::status(401) + .with_header("WWW-Authenticate", "Bearer") + .with_header(VIOLATION_HEADER, code) + .with_body(Bytes::from(body.into_bytes())) +} + +// ----------------------------------------------------------------------------- +// mcp_error_rejection (application-level deny — HTTP 200 + JSON-RPC error) +// ----------------------------------------------------------------------------- + +/// Build an MCP-compliant JSON-RPC error envelope for application-level +/// denials (policy / PDP / PII / delegation failure / internal errors) +/// that the gateway catches BEFORE the upstream tool runs. +/// +/// Per the MCP Tools spec ("Error Handling"), these are *protocol* +/// errors reported via JSON-RPC error envelopes inside an HTTP 200 +/// response — not HTTP 4xx — so MCP clients can correlate the failure +/// to the original request `id` and surface the violation through +/// their normal error UI. +/// +/// Shape (matches the JSON-RPC 2.0 schema referenced by MCP): +/// +/// ```json +/// { +/// "jsonrpc": "2.0", +/// "id": , +/// "error": { +/// "code": -32001, +/// "message": "", +/// "data": { "violation": "" } +/// } +/// } +/// ``` +pub(super) fn mcp_error_rejection( + violation: Option<&PluginViolation>, + request_id: &serde_json::Value, +) -> Rejection { + let bytes = mcp_error_envelope_bytes(violation, request_id); + let violation_code = violation.map_or_else( + || "gateway.unknown".to_owned(), + |v| v.code.clone(), + ); + Rejection::status(200) + .with_header("Content-Type", "application/json") + .with_header(VIOLATION_HEADER, violation_code) + .with_body(bytes) +} + +/// Build only the JSON-RPC error envelope bytes (no HTTP status, no +/// headers). Used by both: +/// +/// * [`mcp_error_rejection`] — pre-upstream denies, where we get to +/// build a full `Rejection` including headers. +/// * `on_response_body` — post-phase denies, where the HTTP status and +/// headers have already been sent to the client; the only thing left +/// to mutate is the body bytes. Replacing the upstream response body +/// with this envelope is the strongest enforcement available from +/// the response body phase under the current praxis API. +pub(super) fn mcp_error_envelope_bytes( + violation: Option<&PluginViolation>, + request_id: &serde_json::Value, +) -> Bytes { + let (violation_code, reason) = match violation { + Some(v) => (v.code.clone(), v.reason.clone()), + None => ( + "gateway.unknown".to_owned(), + "denied by gateway".to_owned(), + ), + }; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": MCP_GATEWAY_DENIED_CODE, + "message": reason, + "data": { "violation": violation_code }, + } + }); + Bytes::from(serde_json::to_vec(&body).unwrap_or_default()) +} diff --git a/filter/src/builtins/http/security/cpex/factories.rs b/filter/src/builtins/http/security/cpex/factories.rs new file mode 100644 index 00000000..704809f1 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/factories.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Plugin factory + PDP factory registrations bundled with the CPEX +//! filter. + +use std::sync::Arc; + +use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_LOGGER_KIND}; +use apl_cpex::{AplOptions, DispatchCache, MemorySessionStore, register_apl}; +use apl_delegator_oauth::{KIND as OAUTH_DELEGATOR_KIND, OAuthDelegatorFactory}; +use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; +use apl_pdp_cedar_direct::CedarDirectPdpFactory; +use apl_pii_scanner::{KIND as PII_SCANNER_KIND, PiiScannerFactory}; +use cpex_core::manager::PluginManager; + +// ----------------------------------------------------------------------------- +// register_builtin_factories +// ----------------------------------------------------------------------------- + +/// Register the plugin factories this filter ships with: +/// +/// * `identity/jwt` — `apl-identity-jwt` (JWT identity resolver) +/// * `delegator/oauth` — `apl-delegator-oauth` (RFC 8693 token exchange) +/// * `validator/pii-scan` — `apl-pii-scanner` (regex-based PII detection) +/// * `audit/logger` — `apl-audit-logger` (structured audit emission) +/// +/// PDP factories (`cedar-direct`) wire via [`register_apl_visitor`] — +/// a different registration surface (`PdpFactory` vs `PluginFactory`). +pub(super) fn register_builtin_factories(mgr: &Arc) { + mgr.register_factory(JWT_KIND, Box::new(JwtIdentityFactory)); + mgr.register_factory(OAUTH_DELEGATOR_KIND, Box::new(OAuthDelegatorFactory)); + mgr.register_factory(PII_SCANNER_KIND, Box::new(PiiScannerFactory)); + mgr.register_factory(AUDIT_LOGGER_KIND, Box::new(AuditLoggerFactory)); +} + +// ----------------------------------------------------------------------------- +// register_apl_visitor +// ----------------------------------------------------------------------------- + +/// Wire the APL visitor onto the manager so it walks `routes:` blocks +/// at config-load time and installs `AplRouteHandler` annotations on +/// the hook table. The baseline is the visitor's default read-only +/// capability set (subject, roles, claims, etc.) — per-plugin caps +/// (`read_inbound_credentials` on the `OAuth` delegator, etc.) are +/// declared in the plugin's YAML `capabilities:` block and unioned +/// into the synthetic route handler by `apl-cpex`. This keeps +/// credential reads scoped to the plugin that declared the need rather +/// than leaking them to every predicate / PDP / step in the same +/// route. +/// +/// Ships the `cedar-direct` PDP factory by default; alternative PDPs +/// (OPA, Cedarling, future engines) slot in similarly. +pub(super) fn register_apl_visitor(mgr: &Arc) { + register_apl( + mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], + base_capabilities: None, + }, + ); +} diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs new file mode 100644 index 00000000..a02054ce --- /dev/null +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -0,0 +1,822 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! `CpexFilter` — embeds the CPEX runtime in-process to resolve and +//! validate identity, evaluate APL routes, optionally mint delegated +//! credentials, scan for PII, emit audit records, and optionally +//! rewrite request/response bodies. + +// The orchestration functions in this module (`on_request_body`, +// `on_response_body`, `new`) coordinate identity resolution, CMF +// dispatch, delegated-token attachment, and body re-serialization in +// linear steps. Splitting them to satisfy `too_many_lines` / +// `cognitive_complexity` would obscure the request/response phase +// flow without reducing real complexity. +#![allow( + clippy::too_many_lines, + clippy::cognitive_complexity, + reason = "orchestration functions; splitting obscures phase flow" +)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +use async_trait::async_trait; +use bytes::Bytes; +use cpex_core::{ + cmf::{CmfHook, Message, MessagePayload, Role}, + error::PluginError, + hooks::Extensions, + identity::{HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource}, + manager::PluginManager, +}; + +use super::{ + cmf::{entity_for_mcp_method, entity_for_mcp_method_post}, + config::{BodyAccessMode, CpexFilterConfig}, + error::{VIOLATION_HEADER, auth_rejection, mcp_error_envelope_bytes, mcp_error_rejection}, + factories::{register_apl_visitor, register_builtin_factories}, + json_rpc::{ + build_content_for_method, build_response_content_for_method, json_rpc_id, json_rpc_id_value, + reserialize_json_rpc_body, reserialize_json_rpc_response_body, + }, +}; +use crate::{ + FilterAction, FilterError, Rejection, + body::{BodyAccess, BodyMode}, + factory::parse_filter_config, + filter::{HttpFilter, HttpFilterContext}, +}; + +// ----------------------------------------------------------------------------- +// CpexFilter +// ----------------------------------------------------------------------------- + +// State of the one-shot tokio runtime-flavor check performed on the +// first request. See `CpexFilter::on_request` for the rationale. + +/// Initial state — no request has been served yet. +const RUNTIME_UNCHECKED: u8 = 0; +/// 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; + +/// Filter that runs the CPEX identity + APL pipeline against each +/// request. +/// +/// 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 praxis's `mcp` filter +/// metadata to dispatch the matching CMF hook chain. APL routes +/// (declared in the CPEX YAML) 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. +/// +/// `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. +/// +/// # YAML configuration +/// +/// Filter fields sit directly under the `- filter:` entry; there is no +/// `config:` wrapper. See `examples/configs/security/cpex.yaml` for a +/// runnable example. +/// +/// ```yaml +/// filter: cpex +/// config_path: /etc/praxis/cpex.yaml +/// body_access: read_write # optional; default read_only +/// require_mcp_metadata: true # optional; default true +/// init_timeout_secs: 30 # optional; default 30 +/// ``` +pub struct CpexFilter { + /// Filter-level configuration parsed from the YAML block. Held so + /// `request_body_access` / `request_body_mode` / their response + /// counterparts can branch on `body_access` per request. + cfg: CpexFilterConfig, + /// CPEX plugin manager — owns the loaded plugin instances and + /// dispatches hook chains. Wrapped in `Arc` so the post-phase + /// `block_in_place` closure can hold its own handle without + /// borrowing `&self`. + mgr: Arc, + /// One-shot runtime-flavor check. `on_response_body` drives async + /// work via `block_in_place`, which panics on a current-thread + /// runtime (praxis `work_stealing: false`). We can't query the + /// flavor from `new()` (no runtime attached yet), so we check on + /// the first request and cache the result. A fuller fix would + /// require `on_response_body` to be async upstream in praxis. + runtime_check: AtomicU8, +} + +impl CpexFilter { + /// Construct a filter from a parsed config. Loads the CPEX YAML + /// referenced by `cfg.config_path`, registers bundled plugin + /// factories, wires the APL visitor, and initializes the manager. + /// Errors abort filter chain construction at server startup — + /// failing fast is what we want for misconfigured policy. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the referenced YAML cannot be read, + /// the policy document fails to parse, or plugin initialization + /// fails (e.g., a JWKS endpoint is unreachable). + pub fn new(cfg: CpexFilterConfig) -> Result { + let yaml = std::fs::read_to_string(&cfg.config_path).map_err(|e| -> FilterError { + format!("cpex: failed to read config_path {}: {e}", cfg.config_path).into() + })?; + + let mgr = Arc::new(PluginManager::default()); + register_builtin_factories(&mgr); + register_apl_visitor(&mgr); + + mgr.load_config_yaml(&yaml) + .map_err(|e: Box| -> FilterError { + format!("cpex: load_config_yaml failed: {e}").into() + })?; + + // `initialize()` is async. The praxis filter-factory signature + // is sync, so we drive init to completion here. We spawn a + // dedicated OS thread to build a single-threaded runtime and + // call `block_on` there — running `block_on` on the current + // thread would panic if any caller (notably `#[tokio::test]`) + // already has a runtime attached. Production startup has no + // caller runtime; tests do; the thread hop is correct in both. + // + // The init future is wrapped in `tokio::time::timeout` so a + // misbehaving plugin's `initialize()` future can't hang startup + // / hot-reload indefinitely. The bundled identity-jwt plugin + // already has its own JWKS connect/request timeouts plus + // soft-fail-at-boot, so this is defense-in-depth for other + // init paths (custom plugins, future hooks) where a future + // 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> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("cpex: failed to build init runtime: {e}"))?; + rt.block_on(async move { + match tokio::time::timeout(init_timeout, mgr_for_init.initialize()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(format!("cpex: PluginManager::initialize failed: {e}")), + Err(_) => Err(format!( + "cpex: PluginManager::initialize timed out after {}s \ + (init_timeout_secs); likely a JWKS / OAuth endpoint is unreachable", + init_timeout.as_secs(), + )), + } + }) + }) + .join() + .map_err(|panic| { + let msg = panic + .downcast_ref::<&str>() + .map(|s| (*s).to_owned()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_owned()); + format!("cpex: PluginManager::initialize panicked in init thread: {msg}") + })?; + init.map_err(|s: String| -> FilterError { s.into() })?; + + Ok(Self { + cfg, + mgr, + runtime_check: AtomicU8::new(RUNTIME_UNCHECKED), + }) + } + + /// Praxis-side factory hook, wired via `register_http` in + /// `filter/src/registry.rs`. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the config block fails to parse + /// as a [`CpexFilterConfig`] or filter construction fails. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: CpexFilterConfig = parse_filter_config("cpex", config)?; + let filter = Self::new(cfg)?; + Ok(Box::new(filter)) + } + + /// Snapshot the request's HTTP headers into a case-normalized + /// map. Each registered identity plugin reads its own configured + /// header from this map. + /// + /// Keys are normalized to ASCII lowercase. HTTP header names are + /// case-insensitive (RFC 7230 §3.2) but the `HashMap` lookup is + /// case-sensitive; plugins lowercase their configured header + /// before lookup to match. + fn snapshot_headers(ctx: &HttpFilterContext<'_>) -> std::collections::HashMap { + ctx.request + .headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|v| (name.as_str().to_ascii_lowercase(), v.to_owned())) + }) + .collect() + } + + /// Build a fresh `IdentityPayload` from request headers. `raw_token` + /// is left empty: each registered identity plugin reads its own + /// configured header from `headers` instead. + fn identity_payload(ctx: &HttpFilterContext<'_>) -> IdentityPayload { + IdentityPayload::new(String::new(), TokenSource::Bearer) + .with_headers(Self::snapshot_headers(ctx)) + } + + /// Build the `Extensions` to feed CMF dispatch. Re-resolves + /// identity (cheap — the JWT verifier hits its in-process key + /// cache), applies the resolved subject / roles / claims / + /// raw-credentials, and stamps `MetaExtension.entity_type` / + /// `entity_name` so route resolution in cpex-core picks the right + /// route annotation. + async fn build_cmf_extensions( + &self, + ctx: &HttpFilterContext<'_>, + entity_type: &str, + entity_name: &str, + ) -> Result { + let (id_result, _bg) = self + .mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + Self::identity_payload(ctx), + Extensions::default(), + None, + ) + .await; + if !id_result.continue_processing { + return Err(auth_rejection(id_result.violation.as_ref())); + } + + let identity = IdentityPayload::from_pipeline_result(&id_result).ok_or_else(|| { + Rejection::status(500).with_body(Bytes::from_static( + b"cpex: identity result missing modified payload", + )) + })?; + let mut ext = identity.apply_to_extensions(Extensions::default()); + + let mut meta = ext + .meta + .as_ref() + .map(|arc| (**arc).clone()) + .unwrap_or_default(); + meta.entity_type = Some(entity_type.to_owned()); + meta.entity_name = Some(entity_name.to_owned()); + ext.meta = Some(Arc::new(meta)); + + Ok(ext) + } +} + +#[async_trait] +impl HttpFilter for CpexFilter { + fn name(&self) -> &'static str { + "cpex" + } + + fn request_body_access(&self) -> BodyAccess { + // `ReadOnly` is the minimum that gets us into `on_request_body` + // (we need the body phase to fire so we can dispatch CMF after + // the `mcp` filter populates its metadata). Operators opt into + // `ReadWrite` via `body_access: read_write` when they want APL + // field mutators (`redact()` / `assign()` on `args.`) to + // rewrite the upstream body. Chain-level scoping keeps non-CPEX + // traffic out of this filter so the buffering cost is bounded + // either way. + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyAccess::ReadOnly, + BodyAccessMode::ReadWrite => BodyAccess::ReadWrite, + } + } + + fn request_body_mode(&self) -> BodyMode { + // In `ReadWrite` mode we MUST buffer the whole body before the + // filter runs — otherwise praxis would stream chunks upstream + // as they arrive, and a body rewrite at end-of-stream would + // race against an already-finished upstream write. + // `StreamBuffer` accumulates chunks, calls our filter exactly + // once at EOS with the full body, and forwards whatever we put + // back into `body`. `ReadOnly` inherits the default `Stream`. + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyMode::Stream, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { max_bytes: None }, + } + } + + fn response_body_access(&self) -> BodyAccess { + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyAccess::ReadOnly, + BodyAccessMode::ReadWrite => BodyAccess::ReadWrite, + } + } + + fn response_body_mode(&self) -> BodyMode { + match self.cfg.body_access { + BodyAccessMode::ReadOnly => BodyMode::Stream, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { max_bytes: None }, + } + } + + async fn on_request( + &self, + ctx: &mut HttpFilterContext<'_>, + ) -> Result { + // One-shot runtime-flavor check. `on_response_body` uses + // `block_in_place` to drive async work from a sync trait + // method, and that primitive panics on a current-thread + // tokio runtime (praxis `work_stealing: false`). Rather than + // crash mid-response, refuse to operate up front. After the + // first request this collapses to a single atomic load. + match self.runtime_check.load(Ordering::Relaxed) { + RUNTIME_UNCHECKED => { + let flavor = tokio::runtime::Handle::current().runtime_flavor(); + if matches!(flavor, tokio::runtime::RuntimeFlavor::CurrentThread) { + self.runtime_check.store(RUNTIME_REJECTED, Ordering::Relaxed); + return Err(current_thread_runtime_error()); + } + self.runtime_check.store(RUNTIME_OK, Ordering::Relaxed); + } + RUNTIME_REJECTED => return Err(current_thread_runtime_error()), + _ => {} // RUNTIME_OK — fall through. + } + + // Early identity gate. Saves the per-request body-buffer cost + // on un-auth'd traffic — if there's no valid token, we never + // reach `on_request_body` and the body never gets buffered. + let (result, _bg) = self + .mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + Self::identity_payload(ctx), + Extensions::default(), + None, + ) + .await; + + if !result.continue_processing { + tracing::debug!(target: "cpex.filter", "identity deny (on_request)"); + return Ok(FilterAction::Reject(auth_rejection(result.violation.as_ref()))); + } + + tracing::trace!(target: "cpex.filter", "identity allow (on_request)"); + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + // CMF dispatch only fires once the full body has been seen + // (so praxis's `mcp` filter has finished parsing and writing + // its metadata). For streaming chunks we just pass. + if !end_of_stream { + return Ok(FilterAction::Continue); + } + + // Pull MCP-derived entity coords from durable filter_metadata. + // Missing `mcp.method` means praxis's built-in `mcp` filter + // didn't run before us — almost always a misconfigured chain + // (missing or ordered after `cpex`). Default to fail-closed + // so the misconfig is loud at first request. Operators + // fronting non-MCP traffic can opt out via + // `require_mcp_metadata: false`. + let Some(method) = ctx.get_metadata("mcp.method").map(str::to_owned) else { + if self.cfg.require_mcp_metadata { + tracing::warn!( + target: "cpex.filter", + "no mcp.method in metadata — likely the `mcp` filter is missing \ + or ordered after `cpex` in the chain; rejecting (set \ + `require_mcp_metadata: false` to disable this guard)", + ); + return Ok(FilterAction::Reject(missing_mcp_metadata_rejection())); + } + tracing::trace!(target: "cpex.filter", "no mcp.method in metadata; no CMF dispatch"); + return Ok(FilterAction::BodyDone); + }; + let Some((entity_type, hook_name)) = entity_for_mcp_method(&method) else { + tracing::trace!( + target: "cpex.filter", + mcp_method = %method, + "MCP method has no entity binding; no CMF dispatch", + ); + return Ok(FilterAction::BodyDone); + }; + let Some(entity_name) = ctx.get_metadata("mcp.name").map(str::to_owned) else { + tracing::debug!( + target: "cpex.filter", + mcp_method = %method, + "MCP method missing mcp.name metadata; skipping CMF dispatch", + ); + return Ok(FilterAction::BodyDone); + }; + + // Build `Extensions` with re-resolved identity + entity coords. + let extensions = match self.build_cmf_extensions(ctx, entity_type, &entity_name).await { + Ok(ext) => ext, + Err(rej) => return Ok(FilterAction::Reject(rej)), + }; + + // Parse the JSON-RPC body to build the typed CMF content part. + // praxis's `mcp` filter already parsed once but only stashed + // method/name in `filter_metadata`, not the `params.arguments` + // that APL `args.*` predicates need. We re-parse here. The + // body is already in memory; the duplicate parse is + // microseconds. + let body_bytes = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let id = json_rpc_id(&body_bytes); + let content = build_content_for_method(&method, &entity_name, &id, &body_bytes); + + // Dispatch the CMF hook. The route annotation (installed by + // the APL visitor at config-load time) drives policy + // evaluation; if no APL route matches, the hook is a no-op. + let payload = MessagePayload { + message: Message::with_content(Role::User, content), + }; + let (cmf_result, _bg) = self + .mgr + .invoke_named::(hook_name, payload, extensions, None) + .await; + + if !cmf_result.continue_processing { + let request_id = json_rpc_id_value(&body_bytes); + tracing::debug!( + target: "cpex.filter", + hook = %hook_name, + entity = %entity_name, + "CMF deny", + ); + return Ok(FilterAction::Reject(mcp_error_rejection( + cmf_result.violation.as_ref(), + &request_id, + ))); + } + + // Allow path. If APL `delegate(...)` steps minted any outbound + // tokens, the delegators wrote them into + // `modified_extensions.raw_credentials.delegated_tokens`. + // Attach each one to the upstream request as the configured + // header. + let attached = attach_delegated_tokens(ctx, cmf_result.modified_extensions.as_ref()); + if attached > 0 { + tracing::debug!( + target: "cpex.filter", + count = attached, + "attached delegated tokens to upstream request", + ); + } + + // If body_access is ReadWrite AND APL mutated the payload + // (a `redact()` / `assign()` step fired), re-serialize the + // mutated `MessagePayload` back into the JSON-RPC body so the + // upstream service receives the rewritten args. + if matches!(self.cfg.body_access, BodyAccessMode::ReadWrite) + && let Some(mp) = cmf_result.modified_payload.as_ref() + && let Some(updated) = mp.as_any().downcast_ref::() + { + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + if let Some(new_bytes) = + reserialize_json_rpc_body(&original, &method, &updated.message) + { + // Praxis recomputes upstream `Content-Length` from the + // rewritten body via `mutated_request_body_len` → + // `apply_mutated_content_length`, so we ship the bytes + // as-is (no pad). Padding here would corrupt byte-exact + // bodies that the upstream verifies via signature / + // hash, and the response-path pad-on-shrink (where + // `Content-Length` IS frozen) is unaffected. + tracing::debug!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len = original.len(), + "rewriting upstream body from mutated MessagePayload", + ); + *body = Some(new_bytes); + } + } + + tracing::trace!( + target: "cpex.filter", + hook = %hook_name, + entity = %entity_name, + "CMF allow", + ); + Ok(FilterAction::BodyDone) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + // No point doing anything if the operator hasn't opted into + // response rewriting. + if !matches!(self.cfg.body_access, BodyAccessMode::ReadWrite) { + return Ok(FilterAction::Continue); + } + + // praxis's `mcp` filter stashes method/name during the request + // phase and praxis preserves `filter_metadata` across phases, + // so we can route the post-phase hook without re-parsing the + // body. + let Some(method) = ctx.get_metadata("mcp.method").map(str::to_owned) else { + return Ok(FilterAction::Continue); + }; + let Some((entity_type, hook_name)) = entity_for_mcp_method_post(&method) else { + return Ok(FilterAction::Continue); + }; + let Some(entity_name) = ctx.get_metadata("mcp.name").map(str::to_owned) else { + return Ok(FilterAction::Continue); + }; + + let body_bytes = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let id_str = json_rpc_id(&body_bytes); + + // praxis's `on_response_body` is sync (the Pingora response_body + // 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(|| { + tokio::runtime::Handle::current().block_on(async { + self.build_cmf_extensions(ctx, entity_type, &entity_name).await + }) + }) { + Ok(e) => e, + Err(_rej) => { + tracing::debug!( + target: "cpex.filter", + "post-phase identity rebuild failed; skipping response rewrite", + ); + return Ok(FilterAction::Continue); + } + }; + + let content = + build_response_content_for_method(&method, &entity_name, &id_str, &body_bytes); + if content.is_empty() { + return Ok(FilterAction::Continue); + } + let payload = MessagePayload { + message: Message::with_content(Role::Assistant, content), + }; + let mgr = Arc::clone(&self.mgr); + let cmf_result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let (r, _bg) = mgr + .invoke_named::(hook_name, payload, extensions, None) + .await; + r + }) + }); + + // Post-phase deny — the upstream's response carries something + // the operator wants suppressed (output PII, late policy + // violation, etc.). We can't change the HTTP status or + // headers from `on_response_body`, but we CAN replace the + // body bytes with a JSON-RPC error envelope so the client + // sees a structured deny instead of the upstream's payload. + // Fits within the original Content-Length via the same + // pad-with-trailing-spaces trick used for ReadWrite rewrites + // (the envelope is almost always shorter than a real + // response body, so padding is the common case). + if !cmf_result.continue_processing { + tracing::warn!( + target: "cpex.filter", + method = %method, + entity = %entity_name, + violation = ?cmf_result.violation, + "post-phase deny — replacing response body with JSON-RPC error envelope", + ); + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + let request_id = json_rpc_id_value(&original); + let envelope = + mcp_error_envelope_bytes(cmf_result.violation.as_ref(), &request_id); + *body = Some(fit_to_original_length(envelope, original.len(), method.as_str(), "post-phase deny")); + return Ok(FilterAction::Continue); + } + + if let Some(mp) = cmf_result.modified_payload.as_ref() + && let Some(updated) = mp.as_any().downcast_ref::() + { + let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); + if let Some(new_bytes) = + reserialize_json_rpc_response_body(&original, &method, &updated.message) + { + let final_bytes = fit_to_original_length( + new_bytes, + original.len(), + method.as_str(), + "response-side rewrite", + ); + tracing::debug!( + target: "cpex.filter", + method = %method, + new_len = final_bytes.len(), + original_len = original.len(), + "rewriting downstream response body from mutated MessagePayload", + ); + *body = Some(final_bytes); + } + } + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// runtime-flavor error +// ----------------------------------------------------------------------------- + +/// Error returned from `on_request` when the filter has been mounted +/// into a current-thread tokio runtime. Hoisted into a helper so the +/// first-request and cached-rejection branches return identical text. +fn current_thread_runtime_error() -> FilterError { + "cpex filter requires a multi-threaded tokio runtime \ + (server config `work_stealing: true`); current-thread runtime \ + is unsupported because response-phase body transformation \ + requires `block_in_place`" + .into() +} + +/// Fit a freshly-built body to the original `Content-Length`: pad with +/// trailing ASCII spaces on shrink (JSON parsers ignore them); pass +/// through with a warning on grow (praxis can recompute +/// `Content-Length` from the request body phase, but NOT from +/// `on_response_body` yet — a longer response body risks HTTP/1.1 +/// framing desync, but dropping the rewrite is worse for the +/// deny-replacement case). +/// +/// Used only on the response side. The request side is unaffected: +/// praxis already repairs request framing via `mutated_request_body_len` +/// → `apply_mutated_content_length` (`stream_buffer.rs` → +/// `with_body.rs`), so padding there would only corrupt byte-exact +/// bodies the upstream might verify via signature / hash. +pub(super) fn fit_to_original_length( + new_bytes: Bytes, + original_len: usize, + method: &str, + reason: &str, +) -> Bytes { + match new_bytes.len().cmp(&original_len) { + std::cmp::Ordering::Less => { + let mut padded = Vec::with_capacity(original_len); + padded.extend_from_slice(&new_bytes); + padded.resize(original_len, b' '); + Bytes::from(padded) + } + std::cmp::Ordering::Equal => new_bytes, + std::cmp::Ordering::Greater => { + tracing::warn!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len, + "{reason}: rewritten body larger than original; sending without pad — \ + peer may see truncation or HTTP/1.1 framing desync", + ); + new_bytes + } + } +} + +/// Rejection emitted when `require_mcp_metadata` is on (default) and +/// no `mcp.method` metadata was set by an upstream filter. HTTP 500 +/// because the misconfiguration is server-side, not client-side. +fn missing_mcp_metadata_rejection() -> Rejection { + Rejection::status(500) + .with_header("Content-Type", "text/plain") + .with_header(VIOLATION_HEADER, "config.missing_mcp_metadata") + .with_body(Bytes::from_static( + b"cpex: no mcp.method in filter metadata. The `mcp` filter must \ + be present in the chain and ordered before `cpex`. Set the \ + filter's `require_mcp_metadata: false` to disable this guard \ + for non-MCP traffic.", + )) +} + +// ----------------------------------------------------------------------------- +// attach_delegated_tokens +// ----------------------------------------------------------------------------- + +/// Walk the minted delegated tokens on the resolved `Extensions` and +/// push them as upstream request headers. Returns the count attached +/// (0 when no delegation ran or no extensions were returned). Each +/// token's `outbound_header` field decides where it goes; the value +/// is `Bearer ` (RFC 6750 wire format — what every audience +/// expects). Uses `request_headers_to_set` rather than +/// `extra_request_headers` because authorization tokens are +/// overwrites, not appends. +/// +/// Multiple tokens targeting the same outbound header are a +/// configuration ambiguity — praxis's `request_headers_to_set` +/// would otherwise let the last writer silently win, with order +/// determined by `HashMap` iteration. Apply first-writer-wins keyed +/// on `(outbound_header_lc, audience)`, log a warn on each skip so +/// the operator can fix the overlapping delegators. +pub(super) fn attach_delegated_tokens( + ctx: &mut HttpFilterContext<'_>, + extensions: Option<&Extensions>, +) -> usize { + let Some(ext) = extensions else { return 0; }; + let Some(raw) = ext.raw_credentials.as_ref() else { + return 0; + }; + + // Stable-order the tokens before we attach. `delegated_tokens` is + // a `HashMap`, so iteration order is non-deterministic — two + // tokens targeting the same outbound header would otherwise + // produce order-dependent results (praxis's + // `request_headers_to_set` is overwrite semantics). Sorting by + // `(outbound_header_lc, audience)` gives first-writer-wins where + // "first" is alphabetically lowest audience for that header. + let mut sorted: Vec<&_> = raw.delegated_tokens.values().collect(); + sorted.sort_by(|a, b| { + a.outbound_header + .to_ascii_lowercase() + .cmp(&b.outbound_header.to_ascii_lowercase()) + .then_with(|| a.audience.cmp(&b.audience)) + }); + + let mut attached_outbound: std::collections::HashSet = + std::collections::HashSet::new(); + let mut count = 0; + for tok in sorted { + let outbound_lc = tok.outbound_header.to_ascii_lowercase(); + if !attached_outbound.insert(outbound_lc.clone()) { + // A token for this outbound header was already attached + // earlier in the sorted pass — refuse to overwrite. Warn + // loudly so an operator notices the policy ambiguity + // (two delegators racing for the same header is almost + // always a mistake in route/global config layering). + tracing::warn!( + target: "cpex.filter", + outbound_header = %tok.outbound_header, + audience = %tok.audience, + "skipping delegated token: another token already targets this outbound header \ + (first-writer-wins by audience asc); fix overlapping delegators in policy", + ); + continue; + } + let Ok(name) = http::header::HeaderName::try_from(tok.outbound_header.as_str()) else { + tracing::warn!( + target: "cpex.filter", + header = %tok.outbound_header, + "delegated token outbound_header is not a valid HTTP header name; skipping", + ); + attached_outbound.remove(&outbound_lc); + continue; + }; + let Ok(value) = http::header::HeaderValue::try_from(format!("Bearer {}", tok.token.as_str())) + else { + tracing::warn!( + target: "cpex.filter", + audience = %tok.audience, + "minted token bytes are not a valid HTTP header value; skipping", + ); + attached_outbound.remove(&outbound_lc); + continue; + }; + ctx.request_headers_to_set.push((name, value)); + count += 1; + } + + // Strip the inbound credential headers — but only when we + // actually attached delegated tokens, and only headers that are + // NOT also being set by an outbound (collision case — + // `request_headers_to_set` overwrites, no remove needed). + if count > 0 { + for inbound in raw.inbound_tokens.values() { + let normalized = inbound.source_header.to_ascii_lowercase(); + if attached_outbound.contains(&normalized) { + continue; + } + if let Ok(n) = http::header::HeaderName::try_from(inbound.source_header.as_str()) { + ctx.request_headers_to_remove.push(n); + } else { + tracing::warn!( + target: "cpex.filter", + header = %inbound.source_header, + "inbound source_header is not a valid HTTP header name; cannot strip", + ); + } + } + } + + count +} diff --git a/filter/src/builtins/http/security/cpex/json_rpc.rs b/filter/src/builtins/http/security/cpex/json_rpc.rs new file mode 100644 index 00000000..2f6b0c26 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/json_rpc.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! JSON-RPC body parsing + typed CMF content-part builders. +// +// The builders/re-serializers branch on MCP method and conditionally +// touch nested envelope fields; `too_many_lines` and +// `cognitive_complexity` fire on the longer ones but the alternatives +// (per-method helpers) hurt readability of a tightly-coupled +// envelope shape. +#![allow( + clippy::too_many_lines, + clippy::cognitive_complexity, + reason = "envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] +//! +//! Praxis's `mcp` filter parses JSON-RPC bodies and stashes +//! `mcp.method` / `mcp.name` in `filter_metadata`, but it doesn't +//! materialize `params.arguments` (or `result.content`) into a typed +//! form that APL `args.*` / `result.*` predicates can evaluate. This +//! module does that second parse, builds the matching `ContentPart`, +//! and re-serializes mutated payloads back into JSON-RPC envelopes +//! when `body_access: read_write` is on. + +use bytes::Bytes; +use cpex_core::cmf::{ + ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult, +}; + +// ----------------------------------------------------------------------------- +// JSON-RPC id extraction +// ----------------------------------------------------------------------------- + +/// Read the JSON-RPC `id` field as a string for use as a CMF +/// correlation id. JSON-RPC permits string or numeric ids; we +/// stringify either to a single canonical key. Returns an empty +/// string when the body is missing or malformed — the correlation +/// id isn't load-bearing for policy, only for audit linkage. +pub(super) fn json_rpc_id(body: &Bytes) -> String { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .map(|id| match id { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .unwrap_or_default() +} + +/// Typed companion to [`json_rpc_id`]. Returns the raw `id` JSON value +/// from the request body — preserves the original shape (string or +/// number) so an MCP error envelope echoes back exactly what the +/// client sent. Returns `Value::Null` when the body is missing or +/// malformed; per JSON-RPC 2.0, an error response MAY use `null` when +/// the original id could not be determined. +pub(super) fn json_rpc_id_value(body: &Bytes) -> serde_json::Value { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .unwrap_or(serde_json::Value::Null) +} + +// ----------------------------------------------------------------------------- +// Request-side: body → typed ContentPart list +// ----------------------------------------------------------------------------- + +/// Build the typed CMF `ContentPart` list for an MCP method. Parses +/// `params` out of the JSON-RPC body so APL `args.*` / `prompt.args.*` +/// / `resource.*` predicates have something to evaluate against. On +/// malformed or absent body, falls back to an empty content list — the +/// caller can still dispatch CMF (entity coords drive routing), just +/// without typed args available to predicates. +pub(super) fn build_content_for_method( + method: &str, + entity_name: &str, + correlation_id: &str, + body: &Bytes, +) -> Vec { + let params: serde_json::Value = serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("params").cloned()) + .unwrap_or(serde_json::Value::Null); + + match method { + "tools/call" => { + let arguments = params + .get("arguments") + .and_then(|v| v.as_object()) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: correlation_id.to_owned(), + name: entity_name.to_owned(), + arguments, + namespace: None, + }, + }] + } + "prompts/get" => { + let arguments = params + .get("arguments") + .and_then(|v| v.as_object()) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + vec![ContentPart::PromptRequest { + content: PromptRequest { + prompt_request_id: correlation_id.to_owned(), + name: entity_name.to_owned(), + arguments, + server_id: None, + }, + }] + } + "resources/read" => { + // For `resources/read`, `params.uri` is the resource + // identifier; `mcp.name` is set to the same URI by praxis's + // `mcp` filter (it treats `uri` as the "selector"). Carry + // it through as the `ResourceReference`. + let uri = params + .get("uri") + .and_then(|v| v.as_str()) + .unwrap_or(entity_name) + .to_owned(); + vec![ContentPart::ResourceRef { + content: ResourceReference { + resource_request_id: correlation_id.to_owned(), + uri, + name: None, + resource_type: ResourceType::Uri, + range_start: None, + range_end: None, + selector: None, + }, + }] + } + _ => Vec::new(), + } +} + +// ----------------------------------------------------------------------------- +// Request-side: re-serialize mutated payload back into the body +// ----------------------------------------------------------------------------- + +/// Re-serialize a JSON-RPC request body, replacing only the fields +/// APL mutated in the typed `MessagePayload`. Returns `Some(new_bytes)` +/// when the body changed, `None` when nothing needed rewriting +/// (no matching content part, malformed original, etc.). +/// +/// Touched fields by MCP method: +/// * `tools/call` → `params.arguments` (from the first +/// `ContentPart::ToolCall.arguments`) +/// * `prompts/get` → `params.arguments` (from the first +/// `ContentPart::PromptRequest.arguments`) +/// * `resources/read` → `params.uri` (from +/// `ContentPart::ResourceRef.uri`) +/// +/// All other JSON-RPC envelope fields (`jsonrpc`, `id`, `method`, +/// `params.name`) pass through unchanged. This minimizes the +/// blast radius of the rewrite — operators relying on a byte-stable +/// envelope (signature validation, content-hash matching) only see +/// changes when APL actually mutated. +pub(super) fn reserialize_json_rpc_body( + original: &Bytes, + method: &str, + message: &Message, +) -> Option { + let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; + let params = envelope.get_mut("params")?; + let params_obj = params.as_object_mut()?; + + match method { + "tools/call" | "prompts/get" => { + for part in &message.content { + let new_args = match part { + ContentPart::ToolCall { content } if method == "tools/call" => { + Some(&content.arguments) + } + ContentPart::PromptRequest { content } if method == "prompts/get" => { + Some(&content.arguments) + } + _ => None, + }; + if let Some(args) = new_args { + let new_args_value: serde_json::Value = args + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + .into(); + params_obj.insert("arguments".to_owned(), new_args_value); + return Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)); + } + } + None + } + "resources/read" => { + for part in &message.content { + if let ContentPart::ResourceRef { content } = part { + params_obj.insert( + "uri".to_owned(), + serde_json::Value::String(content.uri.clone()), + ); + return Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)); + } + } + None + } + _ => None, + } +} + +// ----------------------------------------------------------------------------- +// Response-side: body → typed ContentPart list (post-phase) +// ----------------------------------------------------------------------------- + +/// Build the typed CMF `ContentPart` list from a JSON-RPC *response* +/// body — the post-phase mirror of [`build_content_for_method`]. Today +/// only `tools/call` produces a structured `ToolResult`; `prompts/get` +/// and `resources/read` return TBD shapes the filter can extend later. +/// +/// The actual tool data lives in MCP's `result.content[].text` (a +/// JSON-stringified payload, per the MCP Tools spec) and/or +/// `result.structuredContent` (newer 2025-06-18 shape). We try +/// `structuredContent` first; on miss, parse the first text block's +/// contents as JSON; on parse-miss, wrap the raw text as +/// `{ "text": "" }` so APL `result.text` predicates still resolve. +pub(super) fn build_response_content_for_method( + method: &str, + entity_name: &str, + correlation_id: &str, + body: &Bytes, +) -> Vec { + if method != "tools/call" { + return Vec::new(); + } + let envelope: serde_json::Value = match serde_json::from_slice::(body) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let Some(result) = envelope.get("result") else { + return Vec::new(); + }; + let is_error = result + .get("isError") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let content_value = if let Some(structured) = result.get("structuredContent") { + structured.clone() + } else { + result + .get("content") + .and_then(|c| c.as_array()) + .and_then(|arr| { + arr.iter() + .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + }) + .and_then(|block| block.get("text").and_then(|t| t.as_str())) + .map_or(serde_json::Value::Null, |s| { + serde_json::from_str::(s) + .unwrap_or_else(|_| serde_json::json!({ "text": s })) + }) + }; + + vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: correlation_id.to_owned(), + tool_name: entity_name.to_owned(), + content: content_value, + is_error, + }, + }] +} + +// ----------------------------------------------------------------------------- +// Response-side: re-serialize mutated payload back into the body +// ----------------------------------------------------------------------------- + +/// Re-serialize a JSON-RPC response body, replacing only the fields +/// the post-phase APL pipeline mutated. Mirror of +/// [`reserialize_json_rpc_body`] for the response side. +/// +/// Writes the mutated `ContentPart::ToolResult.content` back into BOTH +/// `result.content[0].text` (as a JSON-stringified payload — the legacy +/// MCP shape every client supports) AND `result.structuredContent` +/// (the typed shape; only set if the original response had it). Keeps +/// unstructured + structured consumers in sync. +pub(super) fn reserialize_json_rpc_response_body( + original: &Bytes, + method: &str, + message: &Message, +) -> Option { + if method != "tools/call" { + return None; + } + let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; + let result = envelope.get_mut("result")?; + let result_obj = result.as_object_mut()?; + + let new_content = message.content.iter().find_map(|part| match part { + ContentPart::ToolResult { content } => Some(content.content.clone()), + _ => None, + })?; + + if let Some(content_arr) = result_obj + .get_mut("content") + .and_then(|c| c.as_array_mut()) + && let Some(first_text) = content_arr.iter_mut().find(|b| { + b.get("type").and_then(|t| t.as_str()) == Some("text") + }) + && let Some(text_obj) = first_text.as_object_mut() + { + text_obj.insert( + "text".to_owned(), + serde_json::Value::String(serde_json::to_string(&new_content).ok()?), + ); + } + + // Only mirror to structuredContent when the original had it. + if result_obj.contains_key("structuredContent") { + result_obj.insert("structuredContent".to_owned(), new_content); + } + + Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)) +} diff --git a/filter/src/builtins/http/security/cpex/mod.rs b/filter/src/builtins/http/security/cpex/mod.rs new file mode 100644 index 00000000..6d319ecb --- /dev/null +++ b/filter/src/builtins/http/security/cpex/mod.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! The CPEX security filter. Resolves multi-source agentic identity, +//! evaluates APL route-level policy, mints RFC 8693 delegated +//! credentials, scans for PII, emits audit records, and (under +//! `body_access: read_write`) rewrites request/response bodies. + +mod cmf; +mod config; +mod error; +mod factories; +mod filter; +mod json_rpc; + +pub use filter::CpexFilter; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::needless_raw_strings, + reason = "tests" +)] +mod tests; diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs new file mode 100644 index 00000000..7139f7f5 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -0,0 +1,1089 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Tests for the CPEX security filter. +//! +//! Uses HMAC (HS256) JWTs throughout for setup simplicity — the +//! identity validation pipeline is symmetric across signing +//! algorithms, and HS256 lets us skip RSA keypair generation. Real +//! deployments use RS256 with JWKS endpoints; the YAML schema +//! supports both via the `decoding_key.kind` discriminant. + +use http::{HeaderValue, Method}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde_json::json; +use tempfile::TempDir; + +use super::config::CpexFilterConfig; +use super::filter::CpexFilter; +use crate::FilterAction; +use crate::filter::HttpFilter; +use crate::test_utils::{make_filter_context, make_request}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +const TEST_SECRET: &str = "praxis-cpex-test-secret-not-for-production-use"; +const TEST_ISSUER: &str = "https://idp.test.local"; +const TEST_AUDIENCE: &str = "test-api"; + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should not be before unix epoch") + .as_secs() +} + +/// Mint an HS256 JWT signed with [`TEST_SECRET`]. +fn mint_jwt(claims: &serde_json::Value) -> String { + let header = Header::new(Algorithm::HS256); + let key = EncodingKey::from_secret(TEST_SECRET.as_bytes()); + encode(&header, claims, &key).expect("sign JWT") +} + +/// Standard token claims: test issuer + audience, fresh `exp`. +fn standard_claims(subject: &str) -> serde_json::Value { + json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "sub": subject, + "exp": now_unix() + 300, + "iat": now_unix(), + }) +} + +/// Claims for a workload / agent token. Includes `azp` (authorized +/// party) so the `client`-role claim mapper can populate the +/// caller-workload identity slot. +fn agent_claims(client_id: &str) -> serde_json::Value { + json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "sub": client_id, + "azp": client_id, + "exp": now_unix() + 300, + "iat": now_unix(), + }) +} + +/// Write a single-plugin CPEX YAML referencing the HS256 test secret. +fn write_single_plugin_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Write a CPEX YAML with two identity plugins, each reading its own +/// header. Demonstrates the multi-source agentic identity story PR1 +/// targets — one request can carry user + agent JWTs simultaneously, +/// both validated, both contributing to a typed `Extensions` context. +#[allow( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_multi_source_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + role: user + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + claim_mapper: standard + - name: jwt-agent + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 20 + on_error: fail + config: + header: X-Agent-Token + role: client + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + claim_mapper: standard +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Build a `CpexFilter` from a YAML config path. Defaults +/// `require_mcp_metadata` to true so the test surface matches the +/// production default; individual tests that want to test the +/// fail-open knob construct their own config. +fn build_filter(config_path: String) -> CpexFilter { + let cfg = CpexFilterConfig { + config_path, + body_access: super::config::BodyAccessMode::ReadOnly, + require_mcp_metadata: true, + init_timeout_secs: 30, + }; + CpexFilter::new(cfg).expect("filter should construct") +} + +// ===================================================================== +// Config parsing +// ===================================================================== + +/// The minimal valid config carries only `config_path:`; all other +/// fields (`body_access`, `require_mcp_metadata`, `init_timeout_secs`) +/// take their documented defaults. +#[test] +fn config_parses_minimal_yaml() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!( + cfg.config_path, "/etc/praxis/cpex.yaml", + "config_path round-trips", + ); +} + +/// `config_path:` is mandatory — there's no default that would let +/// the filter load a CPEX policy document, so an empty config block +/// must fail at deserialize time rather than at first request. +#[test] +fn config_requires_config_path() { + let yaml = "{}"; + let res: Result = serde_yaml::from_str(yaml); + assert!(res.is_err(), "config_path is mandatory"); +} + +// ===================================================================== +// Identity-resolution scenarios +// ===================================================================== + +/// A YAML config carrying a single identity plugin should construct +/// without error. Pins the schema we ship — any drift in the +/// identity/jwt plugin's config shape will surface here. +#[tokio::test(flavor = "multi_thread")] +async fn filter_constructs_from_valid_yaml() { + let (_dir, path) = write_single_plugin_config(); + let _filter = build_filter(path); +} + +/// A request with no `Authorization` header has no token for the JWT +/// plugin to validate; the identity hook chain denies and the filter +/// emits HTTP 401. +#[tokio::test(flavor = "multi_thread")] +async fn request_without_auth_header_rejects_401() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + + match action { + FilterAction::Reject(rej) => assert_eq!(rej.status, 401), + other => panic!("expected Reject(401); got {other:?}"), + } +} + +/// A valid HS256 JWT in the configured header passes the identity +/// chain and the filter emits Continue. +#[tokio::test(flavor = "multi_thread")] +async fn valid_hs256_jwt_continues() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "expected Continue; got {action:?}" + ); +} + +/// A JWT whose signature byte has been flipped fails verification and +/// the filter emits HTTP 401. +#[tokio::test(flavor = "multi_thread")] +async fn tampered_jwt_signature_rejects_401() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + // Flip the final character of the signature segment. + let mut token = mint_jwt(&standard_claims("alice")); + let last = token.pop().unwrap_or('A'); + token.push(if last == 'A' { 'B' } else { 'A' }); + + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(&action, FilterAction::Reject(rej) if rej.status == 401), + "expected Reject(401); got {action:?}" + ); +} + +/// Auth rejections must carry the MCP-spec-required +/// `WWW-Authenticate: Bearer` header so MCP clients know to retry +/// with credentials, plus our `X-Cpex-Violation` diagnostic header. +#[tokio::test(flavor = "multi_thread")] +async fn auth_rejection_carries_diagnostic_headers() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + let FilterAction::Reject(rej) = action else { + panic!("expected Reject; got {action:?}"); + }; + + let www_auth = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("WWW-Authenticate")); + assert!(www_auth.is_some(), "WWW-Authenticate header is required"); + + let violation = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("X-Cpex-Violation")); + assert!(violation.is_some(), "X-Cpex-Violation header is expected"); +} + +/// The PR1 multi-source story: one request carries a user JWT in +/// `Authorization` and an agent JWT in `X-Agent-Token`. Both plugins +/// validate their respective headers and the request passes. +#[tokio::test(flavor = "multi_thread")] +async fn multi_source_both_identities_continue() { + let (_dir, path) = write_multi_source_config(); + let filter = build_filter(path); + + let user_token = mint_jwt(&standard_claims("alice")); + let agent_token = mint_jwt(&agent_claims("agent-007")); + + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {user_token}")).expect("header"), + ); + req.headers.insert( + "X-Agent-Token", + HeaderValue::from_str(&format!("Bearer {agent_token}")).expect("header"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter.on_request(&mut ctx).await.expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "expected Continue with both identities; got {action:?}" + ); +} + +/// `block_in_place` (used by the response phase) panics on a +/// current-thread runtime, so the filter refuses to run there. Pins +/// the rejection so a future change can't silently re-introduce the +/// panic path. The default `#[tokio::test]` flavor is current-thread, +/// which is exactly the runtime praxis configures when +/// `work_stealing: false`. +#[tokio::test] +async fn current_thread_runtime_is_rejected() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + + let err = filter + .on_request(&mut ctx) + .await + .expect_err("current-thread runtime must be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("multi-threaded tokio runtime"), + "error message should point at the work_stealing config; got: {msg}", + ); +} + +// ===================================================================== +// Config-schema guards +// ===================================================================== + +/// `#[serde(deny_unknown_fields)]` must reject typos like `body_acces` +/// — without this, the misspelled field is silently dropped, the +/// default `ReadOnly` mode wins, and `redact()` policies become a +/// no-op. The typo would be invisible to operators until they checked +/// upstream traffic and noticed redaction wasn't happening. +#[test] +fn config_rejects_unknown_fields() { + let yaml = " +config_path: /etc/praxis/cpex.yaml +body_acces: read_write +"; + let res: Result = serde_yaml::from_str(yaml); + assert!( + res.is_err(), + "deny_unknown_fields must reject `body_acces` typo", + ); + let msg = format!("{}", res.unwrap_err()); + assert!( + msg.contains("body_acces") || msg.contains("unknown field"), + "error should name the bad field; got: {msg}", + ); +} + +/// `require_mcp_metadata` defaults to `true` — the safer fail-closed +/// posture. Operators must explicitly opt in to identity-only +/// pass-through for non-MCP traffic. +#[test] +fn config_require_mcp_metadata_defaults_to_true() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert!(cfg.require_mcp_metadata, "default must be fail-closed"); +} + +/// `init_timeout_secs` defaults to 30s when omitted. Operators don't +/// have to think about it; the bound is just present. +#[test] +fn config_init_timeout_defaults_to_30s() { + let yaml = "config_path: /etc/praxis/cpex.yaml"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.init_timeout_secs, 30); +} + +/// An operator-supplied `init_timeout_secs` round-trips. Pins the +/// knob exists at the YAML surface, not just in the struct. +#[test] +fn config_init_timeout_honors_override() { + let yaml = "config_path: /etc/praxis/cpex.yaml\ninit_timeout_secs: 5"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.init_timeout_secs, 5); +} + +// End-to-end exercise of the `init_timeout_secs` knob via the JWKS +// path is intentionally NOT a unit test: the bundled identity-jwt +// plugin has its own JWKS connect/request timeouts plus soft-fail-at- +// boot, so a hung JWKS endpoint never propagates a hang through +// `PluginManager::initialize` in the first place. The wrap-timeout in +// `CpexFilter::new` is defense-in-depth for OTHER init paths (custom +// plugins, future hooks) where a future could legitimately stall. +// The unit tests above pin the surface; the timeout's behavior is +// exercised by `tokio::time::timeout` itself. + +// ===================================================================== +// Fail-closed policy gate (require_mcp_metadata) +// ===================================================================== + +/// When `require_mcp_metadata: true` (default) and `mcp.method` is +/// absent from filter metadata, `on_request_body` rejects with +/// HTTP 500 + `X-Cpex-Violation: config.missing_mcp_metadata`. This +/// catches a misconfigured chain (mcp filter missing or ordered after +/// cpex) loudly at the first body-phase request. +#[tokio::test(flavor = "multi_thread")] +async fn missing_mcp_metadata_rejects_when_required() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter + .on_request_body(&mut ctx, &mut Some(bytes::Bytes::new()), true) + .await + .expect("filter ran"); + match action { + FilterAction::Reject(rej) => { + assert_eq!(rej.status, 500); + let violation = rej + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("X-Cpex-Violation")); + assert!(violation.is_some(), "violation header expected"); + assert_eq!( + violation.unwrap().1, + "config.missing_mcp_metadata", + "violation code should name the missing metadata", + ); + } + other => panic!("expected Reject(500); got {other:?}"), + } +} + +/// When `require_mcp_metadata: false` and `mcp.method` is absent, +/// `on_request_body` passes the request through (identity-only mode +/// for non-MCP traffic). Pins the opt-out behavior. +#[tokio::test(flavor = "multi_thread")] +async fn missing_mcp_metadata_passes_when_not_required() { + let (_dir, path) = write_single_plugin_config(); + let cfg = CpexFilterConfig { + config_path: path, + body_access: super::config::BodyAccessMode::ReadOnly, + require_mcp_metadata: false, + init_timeout_secs: 30, + }; + let filter = CpexFilter::new(cfg).expect("filter should construct"); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + + let action = filter + .on_request_body(&mut ctx, &mut Some(bytes::Bytes::new()), true) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::BodyDone), + "expected BodyDone passthrough; got {action:?}", + ); +} + +// ===================================================================== +// Post-phase deny envelope (mcp_error_envelope_bytes) +// ===================================================================== + +/// The post-phase deny path replaces the response body with this +/// envelope when an APL `result:` pipeline denies. The envelope shape +/// must match the MCP Tools-spec JSON-RPC error format so clients can +/// parse it the same way they parse upstream errors. +#[test] +fn mcp_error_envelope_has_expected_shape() { + use super::error::mcp_error_envelope_bytes; + use cpex_core::error::PluginViolation; + + let violation = PluginViolation::new("test.deny", "policy says no"); + let id = serde_json::json!(42); + let bytes = mcp_error_envelope_bytes(Some(&violation), &id); + + let parsed: serde_json::Value = + serde_json::from_slice(&bytes).expect("envelope must be valid JSON"); + + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 42); + assert_eq!(parsed["error"]["code"], -32001); + assert_eq!(parsed["error"]["message"], "policy says no"); + assert_eq!(parsed["error"]["data"]["violation"], "test.deny"); +} + +/// `request_id` should round-trip preserving the JSON type the client +/// sent — string id stays a string, numeric stays numeric, etc. +/// Pins compliance with the JSON-RPC 2.0 spec. +#[test] +fn mcp_error_envelope_preserves_string_request_id() { + use super::error::mcp_error_envelope_bytes; + let id = serde_json::json!("req-abc-123"); + let bytes = mcp_error_envelope_bytes(None, &id); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); + assert_eq!(parsed["id"], "req-abc-123"); +} + +/// When no violation is provided (defensive null path), the envelope +/// still parses and carries the sentinel `gateway.unknown` code. +#[test] +fn mcp_error_envelope_handles_missing_violation() { + use super::error::mcp_error_envelope_bytes; + let id = serde_json::json!(null); + let bytes = mcp_error_envelope_bytes(None, &id); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); + assert_eq!(parsed["error"]["data"]["violation"], "gateway.unknown"); + assert_eq!(parsed["error"]["message"], "denied by gateway"); +} + +// ===================================================================== +// auth_rejection (transport-level 401) +// ===================================================================== + +/// `auth_rejection` builds an HTTP 401 with `WWW-Authenticate: Bearer` +/// and `X-Cpex-Violation:` reflecting the violation code so audit / +/// middleware can classify without parsing the body. Body carries the +/// short `code: reason` diagnostic. +#[test] +fn auth_rejection_shape_when_violation_present() { + use super::error::auth_rejection; + use cpex_core::error::PluginViolation; + + let violation = PluginViolation::new("auth.invalid_token", "bad signature"); + let rej = auth_rejection(Some(&violation)); + assert_eq!(rej.status, 401); + + let www_auth = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("WWW-Authenticate")); + assert_eq!(www_auth.expect("WWW-Authenticate header").1, "Bearer"); + + let viol = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("X-Cpex-Violation")); + assert_eq!(viol.expect("X-Cpex-Violation header").1, "auth.invalid_token"); + + let body_bytes = rej.body.as_ref().expect("body present"); + let body = std::str::from_utf8(body_bytes).expect("utf8 body"); + assert!( + body.contains("auth.invalid_token") && body.contains("bad signature"), + "body should surface both code and reason; got {body:?}", + ); +} + +/// No violation surfaced still produces a usable 401 with the sentinel +/// `auth.unknown` code — clients always get a structured response. +#[test] +fn auth_rejection_falls_back_to_sentinel_when_no_violation() { + use super::error::auth_rejection; + let rej = auth_rejection(None); + assert_eq!(rej.status, 401); + let viol = rej + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("X-Cpex-Violation")); + assert_eq!(viol.expect("X-Cpex-Violation header").1, "auth.unknown"); +} + +// ===================================================================== +// fit_to_original_length (request/response body framing) +// ===================================================================== + +/// On shrink, `fit_to_original_length` pads the new body with trailing +/// ASCII spaces so the wire length equals the original `Content-Length`. +/// JSON parsers ignore trailing whitespace, so a downstream consumer +/// sees the rewritten envelope without a framing desync. +#[test] +fn fit_to_original_length_pads_on_shrink() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"abc"); + let out = fit_to_original_length(new, 8, "tools/call", "test"); + assert_eq!(out.len(), 8, "padded length must match original"); + assert_eq!(&out[..3], b"abc"); + assert!( + out[3..].iter().all(|b| *b == b' '), + "shrink padding must be ASCII spaces; got {:?}", + &out[3..], + ); +} + +/// On equal-length rewrite, the original bytes pass through unchanged. +/// No allocation, no padding — the common steady-state case for +/// in-place mutations like `redact(value)` swapping a same-width token. +#[test] +fn fit_to_original_length_passes_through_on_equal() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"redacted"); + let out = fit_to_original_length(new.clone(), 8, "tools/call", "test"); + assert_eq!(out, new); +} + +/// On grow, the rewrite passes through and praxis ships the larger body. +/// This risks HTTP/1.1 framing desync (praxis can't recompute +/// `Content-Length` from the body phase yet) but dropping the rewrite +/// is worse for the deny-replacement case. The escape hatch logs a +/// warning via tracing; the test pins only the byte-equality behavior. +#[test] +fn fit_to_original_length_passes_through_on_grow() { + use super::filter::fit_to_original_length; + let new = bytes::Bytes::from_static(b"a much longer rewritten payload"); + let out = fit_to_original_length(new.clone(), 4, "tools/call", "test"); + assert_eq!(out, new); + assert!(out.len() > 4, "grow path must not truncate"); +} + +// ===================================================================== +// cmf.rs — MCP method → entity coords +// ===================================================================== + +/// Pre-phase mapping returns `(entity_type, pre_hook_name)` for the +/// MCP methods that carry an entity, `None` for the no-entity methods. +/// The set is closed; any new MCP method needs an explicit decision. +#[test] +fn entity_for_mcp_method_covers_known_methods() { + use super::cmf::entity_for_mcp_method; + assert!(entity_for_mcp_method("tools/call").is_some()); + assert!(entity_for_mcp_method("prompts/get").is_some()); + assert!(entity_for_mcp_method("resources/read").is_some()); + assert!(entity_for_mcp_method("tools/list").is_none()); + assert!(entity_for_mcp_method("initialize").is_none()); + assert!(entity_for_mcp_method("unknown/method").is_none()); +} + +/// Post-phase mirror — same set of methods, different hooks. +#[test] +fn entity_for_mcp_method_post_covers_known_methods() { + use super::cmf::entity_for_mcp_method_post; + assert!(entity_for_mcp_method_post("tools/call").is_some()); + assert!(entity_for_mcp_method_post("prompts/get").is_some()); + assert!(entity_for_mcp_method_post("resources/read").is_some()); + assert!(entity_for_mcp_method_post("tools/list").is_none()); + assert!(entity_for_mcp_method_post("initialize").is_none()); +} + +// ===================================================================== +// json_rpc.rs — id extraction + content builders + re-serializers +// ===================================================================== + +/// `json_rpc_id` returns the `id` as a string for both string and +/// numeric ids (CMF correlation needs a single canonical key), and +/// falls back to the empty string when the body is missing or malformed. +#[test] +fn json_rpc_id_handles_string_numeric_and_malformed() { + use super::json_rpc::json_rpc_id; + let str_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","id":"req-1","method":"x"}"#); + let num_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","id":42,"method":"x"}"#); + let no_id = bytes::Bytes::from_static(br#"{"jsonrpc":"2.0","method":"x"}"#); + let bad = bytes::Bytes::from_static(b"not json"); + assert_eq!(json_rpc_id(&str_id), "req-1"); + assert_eq!(json_rpc_id(&num_id), "42"); + assert_eq!(json_rpc_id(&no_id), ""); + assert_eq!(json_rpc_id(&bad), ""); +} + +/// `json_rpc_id_value` preserves the original JSON type so an error +/// envelope echoes back exactly what the client sent (string stays a +/// string; numeric stays numeric). Missing/malformed → `Value::Null`. +#[test] +fn json_rpc_id_value_preserves_json_type() { + use super::json_rpc::json_rpc_id_value; + let str_id = bytes::Bytes::from_static(br#"{"id":"req-1"}"#); + let num_id = bytes::Bytes::from_static(br#"{"id":42}"#); + let bad = bytes::Bytes::from_static(b"{"); + assert_eq!(json_rpc_id_value(&str_id), serde_json::json!("req-1")); + assert_eq!(json_rpc_id_value(&num_id), serde_json::json!(42)); + assert_eq!(json_rpc_id_value(&bad), serde_json::Value::Null); +} + +/// `tools/call` parses `params.arguments` into a `ToolCall` content +/// part so APL `args.` predicates have something to read. +#[test] +fn build_content_for_method_tools_call() { + use super::json_rpc::build_content_for_method; + use cpex_core::cmf::ContentPart; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{"text":"hi","n":7}}}"#, + ); + let parts = build_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolCall { content } => { + assert_eq!(content.name, "echo"); + assert_eq!(content.tool_call_id, "corr-1"); + assert_eq!(content.arguments.get("text"), Some(&serde_json::json!("hi"))); + assert_eq!(content.arguments.get("n"), Some(&serde_json::json!(7))); + } + other => panic!("expected ToolCall; got {other:?}"), + } +} + +/// `resources/read` produces a `ResourceRef` keyed off `params.uri` +/// so route resolution and APL `resource.*` predicates work. +#[test] +fn build_content_for_method_resources_read() { + use super::json_rpc::build_content_for_method; + use cpex_core::cmf::ContentPart; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"resources/read", + "params":{"uri":"file:///etc/example"}}"#, + ); + let parts = build_content_for_method("resources/read", "file:///etc/example", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ResourceRef { content } => { + assert_eq!(content.uri, "file:///etc/example"); + assert_eq!(content.resource_request_id, "corr-1"); + } + other => panic!("expected ResourceRef; got {other:?}"), + } +} + +/// Unknown / no-entity MCP methods produce an empty content list — +/// CMF dispatch still routes by entity coords but predicates over +/// `args.*` see nothing, which is the correct behavior. +#[test] +fn build_content_for_method_unknown_method_yields_empty() { + use super::json_rpc::build_content_for_method; + let body = bytes::Bytes::from_static(br#"{"method":"tools/list"}"#); + let parts = build_content_for_method("tools/list", "n/a", "corr-1", &body); + assert!(parts.is_empty()); +} + +/// `reserialize_json_rpc_body` mutates only `params.arguments` (for +/// `tools/call`), leaving `jsonrpc`, `id`, `method`, `params.name` +/// untouched. Operators who hash the envelope only see deltas when +/// APL actually mutated. +#[test] +fn reserialize_tools_call_round_trips_with_mutated_args() { + use super::json_rpc::reserialize_json_rpc_body; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + + let original = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{"a":1}}}"#, + ); + let mut new_args: std::collections::HashMap = + std::collections::HashMap::new(); + new_args.insert("a".to_owned(), serde_json::json!("[REDACTED]")); + let message = Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: String::new(), + name: "echo".to_owned(), + arguments: new_args, + namespace: None, + }, + }], + ); + let new_bytes = + reserialize_json_rpc_body(&original, "tools/call", &message).expect("rewrite Some"); + let parsed: serde_json::Value = serde_json::from_slice(&new_bytes).expect("valid JSON"); + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 1); + assert_eq!(parsed["method"], "tools/call"); + assert_eq!(parsed["params"]["name"], "echo"); + assert_eq!(parsed["params"]["arguments"]["a"], "[REDACTED]"); +} + +/// Response-side: text-only content (no `structuredContent`) is parsed +/// out of the first text block. JSON-string contents resolve to typed +/// `content`; non-JSON text wraps as `{ "text": "" }`. The +/// `isError` flag round-trips. +#[test] +fn build_response_content_for_method_text_fallback() { + use super::json_rpc::build_response_content_for_method; + use cpex_core::cmf::ContentPart; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[{"type":"text","text":"{\"k\":\"v\"}"}], + "isError":false}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + assert!(!content.is_error); + assert_eq!(content.content, serde_json::json!({"k":"v"})); + } + other => panic!("expected ToolResult; got {other:?}"), + } +} + +/// `structuredContent` takes precedence over the text-block fallback +/// when present (newer MCP shape). +#[test] +fn build_response_content_for_method_prefers_structured_content() { + use super::json_rpc::build_response_content_for_method; + use cpex_core::cmf::ContentPart; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[{"type":"text","text":"ignored"}], + "structuredContent":{"hi":"there"}, + "isError":true}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + assert!(content.is_error); + assert_eq!(content.content, serde_json::json!({"hi":"there"})); + } + other => panic!("expected ToolResult; got {other:?}"), + } +} + +// ===================================================================== +// on_request_body — CMF dispatch path (identity-only policy, no routes) +// ===================================================================== + +/// `on_request_body` happy path: valid JWT, `mcp.method=tools/call`, +/// `mcp.name` set. The identity-only policy has no APL routes, so the +/// CMF dispatch finds no matching handler and the request continues. +/// Pins the "CMF dispatch runs without crashing and returns +/// `BodyDone`" branch the reviewer flagged as untested. +#[tokio::test(flavor = "multi_thread")] +async fn on_request_body_dispatches_cmf_when_metadata_present() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + + let token = mint_jwt(&standard_claims("alice")); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", "echo"); + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"echo","arguments":{}}}"#, + ); + + let action = filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::BodyDone), + "no APL route should yield BodyDone; got {action:?}", + ); +} + +/// Non-EOS chunks must pass through untouched — CMF dispatch waits +/// for the full body so praxis's `mcp` filter has finished parsing +/// and writing metadata. Pins the streaming-chunk fast path. +#[tokio::test(flavor = "multi_thread")] +async fn on_request_body_continues_on_partial_chunks() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut chunk = Some(bytes::Bytes::from_static(br#"{"jsonrpc":"2.0""#)); + let action = filter + .on_request_body(&mut ctx, &mut chunk, /*end_of_stream=*/ false) + .await + .expect("filter ran"); + assert!( + matches!(action, FilterAction::Continue), + "non-EOS chunk must Continue without touching body; got {action:?}", + ); +} + +// ===================================================================== +// on_response_body — early returns +// ===================================================================== + +/// In default `body_access: read_only`, `on_response_body` returns +/// `Continue` without doing any work — the operator hasn't opted into +/// response rewriting, and the post-phase deny envelope path is gated +/// on `read_write`. Pins the early-return that keeps the sync hook +/// from blocking on `block_in_place` for read-only chains. +#[test] +fn on_response_body_in_read_only_is_a_no_op() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut body = Some(bytes::Bytes::from_static(b"some upstream body")); + let action = filter + .on_response_body(&mut ctx, &mut body, /*end_of_stream=*/ true) + .expect("hook ran"); + assert!( + matches!(action, FilterAction::Continue), + "ReadOnly response phase must Continue without rewriting; got {action:?}", + ); + assert_eq!( + body.as_deref(), + Some(b"some upstream body".as_slice()), + "body bytes must be untouched in ReadOnly", + ); +} + +/// `on_response_body` returns `Continue` on non-EOS chunks regardless +/// of `body_access`. Mirror of the request-side partial-chunk test. +#[test] +fn on_response_body_continues_on_partial_chunks() { + let (_dir, path) = write_single_plugin_config(); + let filter = build_filter(path); + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let mut chunk = Some(bytes::Bytes::from_static(b"partial")); + let action = filter + .on_response_body(&mut ctx, &mut chunk, /*end_of_stream=*/ false) + .expect("hook ran"); + assert!(matches!(action, FilterAction::Continue)); +} + +// ===================================================================== +// attach_delegated_tokens — outbound header collision handling +// ===================================================================== + +/// Two delegated tokens that both target the same outbound header +/// are a policy-layering mistake (overlapping delegators). Praxis's +/// `request_headers_to_set` is overwrite-semantics and `HashMap` +/// iteration order is non-deterministic, so the naive path would +/// silently pick one. The filter applies first-writer-wins keyed by +/// `(outbound_header_lc, audience)`: only the alphabetically lowest +/// audience attaches, the other is logged and skipped, and the +/// returned count reflects what actually went on the wire. +#[test] +#[allow(clippy::too_many_lines, reason = "test fixture construction")] +fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { + use super::filter::attach_delegated_tokens; + use chrono::{Duration, Utc}; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + }; + use std::sync::Arc; + + let expires = Utc::now() + Duration::hours(1); + let tok_a = RawDelegatedToken::new( + "token-a", + "Authorization", + "aud-a", + Vec::::new(), + expires, + ); + let tok_b = RawDelegatedToken::new( + "token-b", + "Authorization", + "aud-b", + Vec::::new(), + expires, + ); + let key_a = DelegationKey { + subject_id: "alice".to_owned(), + audience: "aud-a".to_owned(), + scopes: Vec::new(), + mode: DelegationMode::OnBehalfOfUser, + }; + let key_b = DelegationKey { + audience: "aud-b".to_owned(), + ..key_a.clone() + }; + let mut creds = RawCredentialsExtension::default(); + creds.delegated_tokens.insert(key_a, tok_a); + creds.delegated_tokens.insert(key_b, tok_b); + let ext = Extensions { + raw_credentials: Some(Arc::new(creds)), + ..Extensions::default() + }; + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let count = attach_delegated_tokens(&mut ctx, Some(&ext)); + + assert_eq!(count, 1, "exactly one token attaches for a colliding header"); + assert_eq!(ctx.request_headers_to_set.len(), 1, "exactly one header push"); + let (name, value) = &ctx.request_headers_to_set[0]; + assert_eq!(name.as_str(), "authorization"); + assert_eq!( + value.to_str().expect("ASCII header value"), + "Bearer token-a", + "first-writer-wins by audience asc must pick aud-a", + ); +} + +/// Sanity: non-colliding tokens for distinct outbound headers all +/// attach. Pins that the collision guard doesn't drop legitimate +/// multi-audience flows (the common case for routes that delegate +/// to multiple upstream APIs simultaneously). +#[test] +#[allow(clippy::too_many_lines, reason = "test fixture construction")] +fn attach_delegated_tokens_distinct_outbound_headers_all_attach() { + use super::filter::attach_delegated_tokens; + use chrono::{Duration, Utc}; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + }; + use std::sync::Arc; + + let expires = Utc::now() + Duration::hours(1); + let tok_auth = RawDelegatedToken::new( + "token-auth", + "Authorization", + "aud-auth", + Vec::::new(), + expires, + ); + let tok_x = RawDelegatedToken::new( + "token-x", + "X-Upstream-Token", + "aud-x", + Vec::::new(), + expires, + ); + let key_auth = DelegationKey { + subject_id: "alice".to_owned(), + audience: "aud-auth".to_owned(), + scopes: Vec::new(), + mode: DelegationMode::OnBehalfOfUser, + }; + let key_x = DelegationKey { + audience: "aud-x".to_owned(), + ..key_auth.clone() + }; + let mut creds = RawCredentialsExtension::default(); + creds.delegated_tokens.insert(key_auth, tok_auth); + creds.delegated_tokens.insert(key_x, tok_x); + let ext = Extensions { + raw_credentials: Some(Arc::new(creds)), + ..Extensions::default() + }; + + let req = make_request(Method::POST, "/"); + let mut ctx = make_filter_context(&req); + let count = attach_delegated_tokens(&mut ctx, Some(&ext)); + + assert_eq!(count, 2, "two distinct headers must both attach"); + assert_eq!(ctx.request_headers_to_set.len(), 2); +} diff --git a/filter/src/builtins/http/security/mod.rs b/filter/src/builtins/http/security/mod.rs index 9261ce98..e4e187cc 100644 --- a/filter/src/builtins/http/security/mod.rs +++ b/filter/src/builtins/http/security/mod.rs @@ -2,9 +2,12 @@ // Copyright (c) 2024 Praxis Contributors //! HTTP security filters: CORS, CSRF, IP access control, credential injection, -//! forwarded-header injection, and guardrails. +//! forwarded-header injection, guardrails, and the (feature-gated) CPEX policy +//! filter. mod cors; +#[cfg(feature = "cpex")] +mod cpex; mod credential_injection; mod csrf; mod forwarded_headers; @@ -13,6 +16,8 @@ mod ip_acl; pub(crate) mod origin_normalize; pub use cors::{CorsFilter, DisallowedOriginMode}; +#[cfg(feature = "cpex")] +pub use cpex::CpexFilter; pub use credential_injection::CredentialInjectionFilter; pub use csrf::CsrfFilter; pub use forwarded_headers::ForwardedHeadersFilter; diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index 0b3e9e1b..a25d2594 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -28,6 +28,8 @@ pub use http::ResponseStoreFilter; pub use http::ResponseStoreRegistry; #[cfg(feature = "ai-inference")] pub use http::ResponsesFormatFilter; +#[cfg(feature = "cpex")] +pub use http::CpexFilter; pub use http::{ A2aFilter, AccessLogFilter, CircuitBreakerFilter, CompressionFilter, ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, ForwardedHeadersFilter, GrpcDetectionFilter, diff --git a/filter/src/lib.rs b/filter/src/lib.rs index 4fc96ec8..e63ff2e0 100644 --- a/filter/src/lib.rs +++ b/filter/src/lib.rs @@ -43,6 +43,8 @@ pub use builtins::PromptEnrichFilter; pub use builtins::ResponseStoreRegistry; #[cfg(feature = "ai-inference")] pub use builtins::ResponsesFormatFilter; +#[cfg(feature = "cpex")] +pub use builtins::CpexFilter; pub use builtins::{ CircuitBreakerFilter, ContainsValue, CredentialInjectionFilter, DisallowedOriginMode, GuardrailsAction, GuardrailsFilter, LoadBalancerFilter, PiiKind, RateLimitMode, RedirectStatus, RouterFilter, RuleTargetKind, diff --git a/filter/src/registry.rs b/filter/src/registry.rs index fcec064e..91900ecc 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -120,6 +120,8 @@ fn register_http_builtins(factories: &mut HashMap) { register_http(factories, "circuit_breaker", CircuitBreakerFilter::from_config); 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); register_http(factories, "csrf", CsrfFilter::from_config); register_http( factories, diff --git a/server/Cargo.toml b/server/Cargo.toml index d24c4007..29d58bdc 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -47,3 +47,4 @@ tempfile = { workspace = true } default = ["ai-inference"] ai-inference = ["praxis-filter/ai-inference", "praxis-protocol/ai-inference"] ext-proc = ["praxis-filter/ext-proc"] +cpex = ["praxis-filter/cpex"] diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index c983a611..592415a8 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -10,6 +10,7 @@ publish = false [features] default = ["ai-inference"] ai-inference = ["praxis-filter/ai-inference"] +cpex = ["praxis-filter/cpex"] no-mac-cert-rotation-tests = [] # We use this to disable testing cert rotation on macOS [lints] diff --git a/tests/integration/tests/suite/examples/cpex.rs b/tests/integration/tests/suite/examples/cpex.rs new file mode 100644 index 00000000..695c4d6c --- /dev/null +++ b/tests/integration/tests/suite/examples/cpex.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Teryl Taylor + +//! Functional integration test for the CPEX example config. +//! +//! Exercises the `examples/configs/security/cpex.yaml` filter chain +//! end-to-end: praxis is configured with the `mcp` → `cpex` → `router` +//! → `load_balancer` chain, an HTTP request is sent without an +//! `Authorization` header, and we assert the filter rejects with +//! HTTP 401 (the cpex identity gate's `auth_rejection` path). +//! +//! Why no happy-path test here: a positive case requires minting an +//! HS256 JWT and constructing a valid MCP JSON-RPC body that praxis's +//! built-in `mcp` filter accepts. The unit tests in +//! `filter/src/builtins/http/security/cpex/tests.rs` cover that path +//! against the filter trait directly. The intent here is the +//! CLAUDE.md "Adding a Filter" integration-test requirement: prove +//! the example config loads, the filter constructs from the policy +//! YAML, and the chain produces the documented error response. + +use std::collections::HashMap; + +use praxis_core::config::Config; +use praxis_test_utils::{ + example_config_path, free_port, http_send, parse_status, patch_yaml, start_backend_with_shutdown, + start_proxy, +}; + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/// Load the CPEX praxis example, patch the relative `config_path` +/// reference into an absolute path, then patch ports. Returns a +/// fully-parsed [`Config`] ready for [`start_proxy`]. +#[allow(clippy::needless_pass_by_value, reason = "callers construct the map inline")] +fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { + let praxis_yaml_path = example_config_path("security/cpex.yaml"); + let policy_yaml_path = example_config_path("security/cpex-policy.yaml"); + + let raw = std::fs::read_to_string(&praxis_yaml_path) + .unwrap_or_else(|e| panic!("read {praxis_yaml_path}: {e}")); + // The example uses a workspace-relative path for the policy file + // because that's what an operator would write. The integration + // test rewrites it to an absolute path so the filter resolves it + // regardless of the test's working directory. + let with_policy = raw.replace( + "examples/configs/security/cpex-policy.yaml", + &policy_yaml_path, + ); + let patched = patch_yaml(&with_policy, proxy_port, &port_map); + Config::from_yaml(&patched).unwrap_or_else(|e| panic!("parse security/cpex.yaml: {e}")) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[test] +fn cpex_example_missing_authorization_rejects_401() { + let backend_guard = start_backend_with_shutdown("ok"); + let proxy_port = free_port(); + let config = load_cpex_example( + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_guard.port())]), + ); + let proxy = start_proxy(&config); + + // POST with a well-formed MCP body but no Authorization header. + // The identity hook chain denies, cpex returns auth_rejection (401 + // with WWW-Authenticate + X-Cpex-Violation headers). + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#; + let raw = http_send( + proxy.addr(), + &format!( + "POST /mcp HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len(), + ), + ); + + assert_eq!( + parse_status(&raw), + 401, + "missing Authorization should hit the cpex identity gate; raw response:\n{raw}", + ); + assert!( + raw.to_lowercase().contains("www-authenticate: bearer"), + "401 must carry WWW-Authenticate per MCP auth spec; raw response:\n{raw}", + ); + assert!( + raw.to_lowercase().contains("x-cpex-violation:"), + "rejection should surface the violation code via X-Cpex-Violation; raw response:\n{raw}", + ); +} diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index f814c557..77989580 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -17,6 +17,8 @@ mod basic_reverse_proxy; mod canary_routing; mod circuit_breaker; mod conditional_filters; +#[cfg(feature = "cpex")] +mod cpex; mod credential_injection; mod csrf; mod default_config; From a5afff2c99ce54c5b5b7fe846104a28b054e68ba Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 15 Jun 2026 17:08:02 -0400 Subject: [PATCH 02/14] fix(security): harden CPEX response-phase body handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.toml | 18 +- filter/src/builtins/http/security/cpex/cmf.rs | 5 +- .../src/builtins/http/security/cpex/config.rs | 2 +- .../src/builtins/http/security/cpex/error.rs | 42 +--- .../src/builtins/http/security/cpex/filter.rs | 188 ++++++++------ .../builtins/http/security/cpex/json_rpc.rs | 122 +++++---- .../src/builtins/http/security/cpex/tests.rs | 234 ++++++++++++------ filter/src/builtins/mod.rs | 4 +- filter/src/lib.rs | 4 +- .../integration/tests/suite/examples/cpex.rs | 16 +- 10 files changed, 353 insertions(+), 282 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8e30d637..95da348a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,14 +37,14 @@ repository = "https://github.com/praxis-proxy/praxis" # CPEX runtime + APL plugins, all pinned to v0.2.0-ffi.test.7. Version # fields and rev bump together when contextforge-org cuts the v0.2.0 # release. -apl-audit-logger = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-cmf = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-core = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-cpex = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-delegator-oauth = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-identity-jwt = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-pdp-cedar-direct = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-pii-scanner = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-audit-logger = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-cmf = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-core = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-cpex = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-delegator-oauth = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-identity-jwt = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-pdp-cedar-direct = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +apl-pii-scanner = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } @@ -52,7 +52,7 @@ bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } dashmap = "6.2.1" clap = { version = "4.6.1", features = ["derive"] } -cpex-core = { version = "0.1", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +cpex-core = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } criterion = { version = "0.8.2", features = ["async_tokio"] } futures = "0.3.32" h2 = "0.4.15" diff --git a/filter/src/builtins/http/security/cpex/cmf.rs b/filter/src/builtins/http/security/cpex/cmf.rs index 7591b09e..42781d27 100644 --- a/filter/src/builtins/http/security/cpex/cmf.rs +++ b/filter/src/builtins/http/security/cpex/cmf.rs @@ -38,9 +38,8 @@ //! through to the identity-only path. use cpex_core::cmf::constants::{ - ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_PROMPT_POST_INVOKE, - HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, - HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, + ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, + HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, }; // ----------------------------------------------------------------------------- diff --git a/filter/src/builtins/http/security/cpex/config.rs b/filter/src/builtins/http/security/cpex/config.rs index adc4473f..0c189a29 100644 --- a/filter/src/builtins/http/security/cpex/config.rs +++ b/filter/src/builtins/http/security/cpex/config.rs @@ -35,7 +35,7 @@ pub struct CpexFilterConfig { pub config_path: String, /// Body-access tier. `ReadOnly` (default) lets APL inspect request - /// + response bodies for routing / policy decisions but discards + /// 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 diff --git a/filter/src/builtins/http/security/cpex/error.rs b/filter/src/builtins/http/security/cpex/error.rs index a3ef4a56..1f456907 100644 --- a/filter/src/builtins/http/security/cpex/error.rs +++ b/filter/src/builtins/http/security/cpex/error.rs @@ -27,10 +27,8 @@ const MCP_GATEWAY_DENIED_CODE: i64 = -32001; /// parsing the body. Sent on: /// /// * HTTP 401 ([`auth_rejection`]) — identity / transport-level deny. -/// * HTTP 200 ([`mcp_error_rejection`]) — application-level deny -/// wrapped in a JSON-RPC error envelope. -/// * HTTP 500 ([`super::filter::missing_mcp_metadata_rejection`]) — -/// `mcp.method` missing from filter metadata. +/// * HTTP 200 ([`mcp_error_rejection`]) — application-level deny wrapped in a JSON-RPC error envelope. +/// * HTTP 500 ([`super::filter::missing_mcp_metadata_rejection`]) — `mcp.method` missing from filter metadata. /// /// Operators consuming this in audit / SIEM pipelines should treat the /// header value as a stable identifier (the code namespace is part of @@ -57,10 +55,7 @@ pub(super) const VIOLATION_HEADER: &str = "X-Cpex-Violation"; pub(super) fn auth_rejection(violation: Option<&PluginViolation>) -> Rejection { let (code, reason) = match violation { Some(v) => (v.code.clone(), v.reason.clone()), - None => ( - "auth.unknown".to_owned(), - "authentication required".to_owned(), - ), + None => ("auth.unknown".to_owned(), "authentication required".to_owned()), }; let body = format!("{code}: {reason}"); Rejection::status(401) @@ -96,15 +91,9 @@ pub(super) fn auth_rejection(violation: Option<&PluginViolation>) -> Rejection { /// } /// } /// ``` -pub(super) fn mcp_error_rejection( - violation: Option<&PluginViolation>, - request_id: &serde_json::Value, -) -> Rejection { +pub(super) fn mcp_error_rejection(violation: Option<&PluginViolation>, request_id: &serde_json::Value) -> Rejection { let bytes = mcp_error_envelope_bytes(violation, request_id); - let violation_code = violation.map_or_else( - || "gateway.unknown".to_owned(), - |v| v.code.clone(), - ); + let violation_code = violation.map_or_else(|| "gateway.unknown".to_owned(), |v| v.code.clone()); Rejection::status(200) .with_header("Content-Type", "application/json") .with_header(VIOLATION_HEADER, violation_code) @@ -114,23 +103,14 @@ pub(super) fn mcp_error_rejection( /// Build only the JSON-RPC error envelope bytes (no HTTP status, no /// headers). Used by both: /// -/// * [`mcp_error_rejection`] — pre-upstream denies, where we get to -/// build a full `Rejection` including headers. -/// * `on_response_body` — post-phase denies, where the HTTP status and -/// headers have already been sent to the client; the only thing left -/// to mutate is the body bytes. Replacing the upstream response body -/// with this envelope is the strongest enforcement available from -/// the response body phase under the current praxis API. -pub(super) fn mcp_error_envelope_bytes( - violation: Option<&PluginViolation>, - request_id: &serde_json::Value, -) -> Bytes { +/// * [`mcp_error_rejection`] — pre-upstream denies, where we get to build a full `Rejection` including headers. +/// * `on_response_body` — post-phase denies, where the HTTP status and headers have already been sent to the client; +/// the only thing left to mutate is the body bytes. Replacing the upstream response body with this envelope is the +/// strongest enforcement available from the response body phase under the current praxis API. +pub(super) fn mcp_error_envelope_bytes(violation: Option<&PluginViolation>, request_id: &serde_json::Value) -> Bytes { let (violation_code, reason) = match violation { Some(v) => (v.code.clone(), v.reason.clone()), - None => ( - "gateway.unknown".to_owned(), - "denied by gateway".to_owned(), - ), + None => ("gateway.unknown".to_owned(), "denied by gateway".to_owned()), }; let body = serde_json::json!({ "jsonrpc": "2.0", diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs index a02054ce..062b851b 100644 --- a/filter/src/builtins/http/security/cpex/filter.rs +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -18,14 +18,16 @@ reason = "orchestration functions; splitting obscures phase flow" )] -use std::sync::Arc; -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; use async_trait::async_trait; use bytes::Bytes; use cpex_core::{ cmf::{CmfHook, Message, MessagePayload, Role}, - error::PluginError, + error::{PluginError, PluginViolation}, hooks::Extensions, identity::{HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource}, manager::PluginManager, @@ -136,9 +138,7 @@ impl CpexFilter { register_apl_visitor(&mgr); mgr.load_config_yaml(&yaml) - .map_err(|e: Box| -> FilterError { - format!("cpex: load_config_yaml failed: {e}").into() - })?; + .map_err(|e: Box| -> FilterError { format!("cpex: load_config_yaml failed: {e}").into() })?; // `initialize()` is async. The praxis filter-factory signature // is sync, so we drive init to completion here. We spawn a @@ -230,8 +230,7 @@ impl CpexFilter { /// is left empty: each registered identity plugin reads its own /// configured header from `headers` instead. fn identity_payload(ctx: &HttpFilterContext<'_>) -> IdentityPayload { - IdentityPayload::new(String::new(), TokenSource::Bearer) - .with_headers(Self::snapshot_headers(ctx)) + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(Self::snapshot_headers(ctx)) } /// Build the `Extensions` to feed CMF dispatch. Re-resolves @@ -260,17 +259,11 @@ impl CpexFilter { } let identity = IdentityPayload::from_pipeline_result(&id_result).ok_or_else(|| { - Rejection::status(500).with_body(Bytes::from_static( - b"cpex: identity result missing modified payload", - )) + Rejection::status(500).with_body(Bytes::from_static(b"cpex: identity result missing modified payload")) })?; let mut ext = identity.apply_to_extensions(Extensions::default()); - let mut meta = ext - .meta - .as_ref() - .map(|arc| (**arc).clone()) - .unwrap_or_default(); + let mut meta = ext.meta.as_ref().map(|arc| (**arc).clone()).unwrap_or_default(); meta.entity_type = Some(entity_type.to_owned()); meta.entity_name = Some(entity_name.to_owned()); ext.meta = Some(Arc::new(meta)); @@ -328,10 +321,7 @@ impl HttpFilter for CpexFilter { } } - async fn on_request( - &self, - ctx: &mut HttpFilterContext<'_>, - ) -> Result { + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { // One-shot runtime-flavor check. `on_response_body` uses // `block_in_place` to drive async work from a sync trait // method, and that primitive panics on a current-thread @@ -346,9 +336,9 @@ impl HttpFilter for CpexFilter { return Err(current_thread_runtime_error()); } self.runtime_check.store(RUNTIME_OK, Ordering::Relaxed); - } + }, RUNTIME_REJECTED => return Err(current_thread_runtime_error()), - _ => {} // RUNTIME_OK — fall through. + _ => {}, // RUNTIME_OK — fall through. } // Early identity gate. Saves the per-request body-buffer cost @@ -487,9 +477,7 @@ impl HttpFilter for CpexFilter { && let Some(updated) = mp.as_any().downcast_ref::() { let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); - if let Some(new_bytes) = - reserialize_json_rpc_body(&original, &method, &updated.message) - { + if let Some(new_bytes) = reserialize_json_rpc_body(&original, &method, &updated.message) { // Praxis recomputes upstream `Content-Length` from the // rewritten body via `mutated_request_body_len` → // `apply_mutated_content_length`, so we ship the bytes @@ -554,22 +542,43 @@ impl HttpFilter for CpexFilter { // `block_in_place` lets us drive the async CMF dispatch without // stalling other tasks. let extensions = match tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - self.build_cmf_extensions(ctx, entity_type, &entity_name).await - }) + tokio::runtime::Handle::current() + .block_on(async { self.build_cmf_extensions(ctx, entity_type, &entity_name).await }) }) { Ok(e) => e, Err(_rej) => { - tracing::debug!( + // Fail closed, symmetric with the request phase: a + // request whose identity can't be resolved is denied, + // so a response we can no longer attribute must be + // denied too. Passing it through would skip any + // configured response-side redaction and leak the + // upstream payload. We can't change the already-sent + // status/headers, but we can replace the body with a + // deny envelope fitted to the committed length. + tracing::warn!( target: "cpex.filter", - "post-phase identity rebuild failed; skipping response rewrite", + method = %method, + entity = %entity_name, + "post-phase identity rebuild failed; failing closed \ + (replacing response body with deny envelope)", ); + let request_id = json_rpc_id_value(&body_bytes); + let violation = PluginViolation::new( + "identity.post_phase_unavailable", + "identity could not be re-resolved for response processing", + ); + let envelope = mcp_error_envelope_bytes(Some(&violation), &request_id); + *body = Some(fit_to_original_length( + envelope, + body_bytes.len(), + method.as_str(), + "post-phase identity failure", + )); return Ok(FilterAction::Continue); - } + }, }; - let content = - build_response_content_for_method(&method, &entity_name, &id_str, &body_bytes); + let content = build_response_content_for_method(&method, &entity_name, &id_str, &body_bytes); if content.is_empty() { return Ok(FilterAction::Continue); } @@ -579,9 +588,7 @@ impl HttpFilter for CpexFilter { let mgr = Arc::clone(&self.mgr); let cmf_result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - let (r, _bg) = mgr - .invoke_named::(hook_name, payload, extensions, None) - .await; + let (r, _bg) = mgr.invoke_named::(hook_name, payload, extensions, None).await; r }) }); @@ -606,9 +613,13 @@ impl HttpFilter for CpexFilter { ); let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); let request_id = json_rpc_id_value(&original); - let envelope = - mcp_error_envelope_bytes(cmf_result.violation.as_ref(), &request_id); - *body = Some(fit_to_original_length(envelope, original.len(), method.as_str(), "post-phase deny")); + let envelope = mcp_error_envelope_bytes(cmf_result.violation.as_ref(), &request_id); + *body = Some(fit_to_original_length( + envelope, + original.len(), + method.as_str(), + "post-phase deny", + )); return Ok(FilterAction::Continue); } @@ -616,15 +627,33 @@ impl HttpFilter for CpexFilter { && let Some(updated) = mp.as_any().downcast_ref::() { let original = body.as_ref().cloned().unwrap_or_else(Bytes::new); - if let Some(new_bytes) = - reserialize_json_rpc_response_body(&original, &method, &updated.message) - { - let final_bytes = fit_to_original_length( - new_bytes, - original.len(), - method.as_str(), - "response-side rewrite", - ); + if let Some(new_bytes) = reserialize_json_rpc_response_body(&original, &method, &updated.message) { + let final_bytes = if new_bytes.len() > original.len() { + // The rewrite grew the body past the committed + // response Content-Length. We can't enlarge the + // response, and truncating the redacted payload would + // ship corrupt JSON. Fail closed: replace it with a + // structured deny envelope fitted to length, so the + // client gets a clean error rather than a mangled + // (and potentially under-redacted) body. + tracing::warn!( + target: "cpex.filter", + method = %method, + new_len = new_bytes.len(), + original_len = original.len(), + "response rewrite exceeds committed Content-Length; \ + failing closed with deny envelope", + ); + let request_id = json_rpc_id_value(&original); + let violation = PluginViolation::new( + "gateway.response_rewrite_overflow", + "response rewrite exceeded the committed response length", + ); + let envelope = mcp_error_envelope_bytes(Some(&violation), &request_id); + fit_to_original_length(envelope, original.len(), method.as_str(), "response rewrite overflow") + } else { + fit_to_original_length(new_bytes, original.len(), method.as_str(), "response-side rewrite") + }; tracing::debug!( target: "cpex.filter", method = %method, @@ -654,32 +683,35 @@ fn current_thread_runtime_error() -> FilterError { .into() } -/// Fit a freshly-built body to the original `Content-Length`: pad with -/// trailing ASCII spaces on shrink (JSON parsers ignore them); pass -/// through with a warning on grow (praxis can recompute -/// `Content-Length` from the request body phase, but NOT from -/// `on_response_body` yet — a longer response body risks HTTP/1.1 -/// framing desync, but dropping the rewrite is worse for the -/// deny-replacement case). +/// Fit a freshly-built body to the original `Content-Length`, always +/// returning **exactly** `original_len` bytes: pad with trailing ASCII +/// spaces on shrink (JSON parsers ignore them); truncate on grow. +/// +/// The downstream response `Content-Length` is committed by the time +/// `on_response_body` runs — praxis has no response-side equivalent of +/// `apply_mutated_content_length` (that path is request-only). Emitting +/// more bytes than `original_len` is therefore an HTTP/1.1 framing +/// desync: the trailing bytes would be parsed as the start of the next +/// response (a response-smuggling primitive). Truncating to +/// `original_len` corrupts the JSON the client parses but cannot smuggle +/// — it is the safe failure mode. Callers that can do better (the +/// response-rewrite path) substitute a length-fitting deny envelope +/// before reaching the grow case, so truncation is a last-resort +/// backstop, not the common path. /// /// Used only on the response side. The request side is unaffected: -/// praxis already repairs request framing via `mutated_request_body_len` -/// → `apply_mutated_content_length` (`stream_buffer.rs` → -/// `with_body.rs`), so padding there would only corrupt byte-exact -/// bodies the upstream might verify via signature / hash. -pub(super) fn fit_to_original_length( - new_bytes: Bytes, - original_len: usize, - method: &str, - reason: &str, -) -> Bytes { +/// praxis repairs request framing via `mutated_request_body_len` → +/// `apply_mutated_content_length` (`stream_buffer.rs` → `with_body.rs`), +/// so padding there would only corrupt byte-exact bodies the upstream +/// might verify via signature / hash. +pub(super) fn fit_to_original_length(new_bytes: Bytes, original_len: usize, method: &str, reason: &str) -> Bytes { match new_bytes.len().cmp(&original_len) { std::cmp::Ordering::Less => { let mut padded = Vec::with_capacity(original_len); padded.extend_from_slice(&new_bytes); padded.resize(original_len, b' '); Bytes::from(padded) - } + }, std::cmp::Ordering::Equal => new_bytes, std::cmp::Ordering::Greater => { tracing::warn!( @@ -687,11 +719,12 @@ pub(super) fn fit_to_original_length( method = %method, new_len = new_bytes.len(), original_len, - "{reason}: rewritten body larger than original; sending without pad — \ - peer may see truncation or HTTP/1.1 framing desync", + "{reason}: rewritten body larger than original Content-Length; \ + truncating to preserve HTTP/1.1 framing (response Content-Length \ + is already committed and cannot grow)", ); - new_bytes - } + new_bytes.slice(0..original_len) + }, } } @@ -729,11 +762,10 @@ fn missing_mcp_metadata_rejection() -> Rejection { /// determined by `HashMap` iteration. Apply first-writer-wins keyed /// on `(outbound_header_lc, audience)`, log a warn on each skip so /// the operator can fix the overlapping delegators. -pub(super) fn attach_delegated_tokens( - ctx: &mut HttpFilterContext<'_>, - extensions: Option<&Extensions>, -) -> usize { - let Some(ext) = extensions else { return 0; }; +pub(super) fn attach_delegated_tokens(ctx: &mut HttpFilterContext<'_>, extensions: Option<&Extensions>) -> usize { + let Some(ext) = extensions else { + return 0; + }; let Some(raw) = ext.raw_credentials.as_ref() else { return 0; }; @@ -753,8 +785,7 @@ pub(super) fn attach_delegated_tokens( .then_with(|| a.audience.cmp(&b.audience)) }); - let mut attached_outbound: std::collections::HashSet = - std::collections::HashSet::new(); + let mut attached_outbound: std::collections::HashSet = std::collections::HashSet::new(); let mut count = 0; for tok in sorted { let outbound_lc = tok.outbound_header.to_ascii_lowercase(); @@ -782,8 +813,7 @@ pub(super) fn attach_delegated_tokens( attached_outbound.remove(&outbound_lc); continue; }; - let Ok(value) = http::header::HeaderValue::try_from(format!("Bearer {}", tok.token.as_str())) - else { + let Ok(value) = http::header::HeaderValue::try_from(format!("Bearer {}", tok.token.as_str())) else { tracing::warn!( target: "cpex.filter", audience = %tok.audience, diff --git a/filter/src/builtins/http/security/cpex/json_rpc.rs b/filter/src/builtins/http/security/cpex/json_rpc.rs index 2f6b0c26..97e81d0f 100644 --- a/filter/src/builtins/http/security/cpex/json_rpc.rs +++ b/filter/src/builtins/http/security/cpex/json_rpc.rs @@ -2,7 +2,6 @@ // Copyright (c) 2026 Praxis Contributors //! JSON-RPC body parsing + typed CMF content-part builders. -// // The builders/re-serializers branch on MCP method and conditionally // touch nested envelope fields; `too_many_lines` and // `cognitive_complexity` fire on the longer ones but the alternatives @@ -13,7 +12,6 @@ clippy::cognitive_complexity, reason = "envelope orchestration; splitting per-method obscures the JSON-RPC shape" )] -//! //! Praxis's `mcp` filter parses JSON-RPC bodies and stashes //! `mcp.method` / `mcp.name` in `filter_metadata`, but it doesn't //! materialize `params.arguments` (or `result.content`) into a typed @@ -23,9 +21,7 @@ //! when `body_access: read_write` is on. use bytes::Bytes; -use cpex_core::cmf::{ - ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult, -}; +use cpex_core::cmf::{ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult}; // ----------------------------------------------------------------------------- // JSON-RPC id extraction @@ -96,7 +92,7 @@ pub(super) fn build_content_for_method( namespace: None, }, }] - } + }, "prompts/get" => { let arguments = params .get("arguments") @@ -111,7 +107,7 @@ pub(super) fn build_content_for_method( server_id: None, }, }] - } + }, "resources/read" => { // For `resources/read`, `params.uri` is the resource // identifier; `mcp.name` is set to the same URI by praxis's @@ -133,7 +129,7 @@ pub(super) fn build_content_for_method( selector: None, }, }] - } + }, _ => Vec::new(), } } @@ -148,23 +144,16 @@ pub(super) fn build_content_for_method( /// (no matching content part, malformed original, etc.). /// /// Touched fields by MCP method: -/// * `tools/call` → `params.arguments` (from the first -/// `ContentPart::ToolCall.arguments`) -/// * `prompts/get` → `params.arguments` (from the first -/// `ContentPart::PromptRequest.arguments`) -/// * `resources/read` → `params.uri` (from -/// `ContentPart::ResourceRef.uri`) +/// * `tools/call` → `params.arguments` (from the first `ContentPart::ToolCall.arguments`) +/// * `prompts/get` → `params.arguments` (from the first `ContentPart::PromptRequest.arguments`) +/// * `resources/read` → `params.uri` (from `ContentPart::ResourceRef.uri`) /// /// All other JSON-RPC envelope fields (`jsonrpc`, `id`, `method`, /// `params.name`) pass through unchanged. This minimizes the /// blast radius of the rewrite — operators relying on a byte-stable /// envelope (signature validation, content-hash matching) only see /// changes when APL actually mutated. -pub(super) fn reserialize_json_rpc_body( - original: &Bytes, - method: &str, - message: &Message, -) -> Option { +pub(super) fn reserialize_json_rpc_body(original: &Bytes, method: &str, message: &Message) -> Option { let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; let params = envelope.get_mut("params")?; let params_obj = params.as_object_mut()?; @@ -173,12 +162,8 @@ pub(super) fn reserialize_json_rpc_body( "tools/call" | "prompts/get" => { for part in &message.content { let new_args = match part { - ContentPart::ToolCall { content } if method == "tools/call" => { - Some(&content.arguments) - } - ContentPart::PromptRequest { content } if method == "prompts/get" => { - Some(&content.arguments) - } + ContentPart::ToolCall { content } if method == "tools/call" => Some(&content.arguments), + ContentPart::PromptRequest { content } if method == "prompts/get" => Some(&content.arguments), _ => None, }; if let Some(args) = new_args { @@ -192,19 +177,16 @@ pub(super) fn reserialize_json_rpc_body( } } None - } + }, "resources/read" => { for part in &message.content { if let ContentPart::ResourceRef { content } = part { - params_obj.insert( - "uri".to_owned(), - serde_json::Value::String(content.uri.clone()), - ); + params_obj.insert("uri".to_owned(), serde_json::Value::String(content.uri.clone())); return Some(Bytes::from(serde_json::to_vec(&envelope).ok()?)); } } None - } + }, _ => None, } } @@ -221,9 +203,18 @@ pub(super) fn reserialize_json_rpc_body( /// The actual tool data lives in MCP's `result.content[].text` (a /// JSON-stringified payload, per the MCP Tools spec) and/or /// `result.structuredContent` (newer 2025-06-18 shape). We try -/// `structuredContent` first; on miss, parse the first text block's -/// contents as JSON; on parse-miss, wrap the raw text as -/// `{ "text": "" }` so APL `result.text` predicates still resolve. +/// `structuredContent` first; on miss, fold **every** text block (not +/// just the first) so APL evaluates against all of the response's text +/// content. A lone text block that parses as JSON is exposed as that +/// object (so `result.` predicates resolve); otherwise the raw +/// text — or the concatenation of multiple blocks — is wrapped as +/// `{ "text": "" }` so `result.text` predicates still resolve. +/// +/// Folding all blocks is load-bearing for policy: if APL only saw the +/// first block, a later block could carry data the policy never vetted +/// and [`reserialize_json_rpc_response_body`] never rewrote, leaking it +/// downstream. The view here and the bytes emitted there are kept to +/// the same content set. pub(super) fn build_response_content_for_method( method: &str, entity_name: &str, @@ -248,18 +239,26 @@ pub(super) fn build_response_content_for_method( let content_value = if let Some(structured) = result.get("structuredContent") { structured.clone() } else { - result + let texts: Vec<&str> = result .get("content") .and_then(|c| c.as_array()) - .and_then(|arr| { + .map(|arr| { arr.iter() - .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - }) - .and_then(|block| block.get("text").and_then(|t| t.as_str())) - .map_or(serde_json::Value::Null, |s| { - serde_json::from_str::(s) - .unwrap_or_else(|_| serde_json::json!({ "text": s })) + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect() }) + .unwrap_or_default(); + match texts.as_slice() { + [] => serde_json::Value::Null, + [single] => serde_json::from_str::(single) + .unwrap_or_else(|_| serde_json::json!({ "text": single })), + // Multiple text blocks can't be merged into one JSON object + // unambiguously; expose their concatenation under `text` so + // every block is in APL's view (and gets collapsed into a + // single vetted block on re-serialization). + many => serde_json::json!({ "text": many.join("\n") }), + } }; vec![ContentPart::ToolResult { @@ -280,16 +279,19 @@ pub(super) fn build_response_content_for_method( /// the post-phase APL pipeline mutated. Mirror of /// [`reserialize_json_rpc_body`] for the response side. /// -/// Writes the mutated `ContentPart::ToolResult.content` back into BOTH -/// `result.content[0].text` (as a JSON-stringified payload — the legacy -/// MCP shape every client supports) AND `result.structuredContent` -/// (the typed shape; only set if the original response had it). Keeps -/// unstructured + structured consumers in sync. -pub(super) fn reserialize_json_rpc_response_body( - original: &Bytes, - method: &str, - message: &Message, -) -> Option { +/// When the original response carried a `result.content` array, the +/// **entire** array is replaced with a single canonical text block +/// holding the vetted `ToolResult.content` (JSON-stringified — the +/// legacy MCP shape every client supports). Collapsing to one block is +/// deliberate: [`build_response_content_for_method`] folds every text +/// block into the single value APL evaluates, so any *other* block left +/// in place here would be content the policy never inspected and never +/// rewrote — a redaction bypass. Dropping the extra (text and non-text) +/// blocks guarantees the bytes we emit are exactly what APL vetted. +/// +/// `result.structuredContent` is mirrored to the same value, but only +/// when the original response already had it (we don't invent fields). +pub(super) fn reserialize_json_rpc_response_body(original: &Bytes, method: &str, message: &Message) -> Option { if method != "tools/call" { return None; } @@ -302,17 +304,11 @@ pub(super) fn reserialize_json_rpc_response_body( _ => None, })?; - if let Some(content_arr) = result_obj - .get_mut("content") - .and_then(|c| c.as_array_mut()) - && let Some(first_text) = content_arr.iter_mut().find(|b| { - b.get("type").and_then(|t| t.as_str()) == Some("text") - }) - && let Some(text_obj) = first_text.as_object_mut() - { - text_obj.insert( - "text".to_owned(), - serde_json::Value::String(serde_json::to_string(&new_content).ok()?), + if result_obj.contains_key("content") { + let text = serde_json::to_string(&new_content).ok()?; + result_obj.insert( + "content".to_owned(), + serde_json::json!([{ "type": "text", "text": text }]), ); } diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs index 7139f7f5..209203e1 100644 --- a/filter/src/builtins/http/security/cpex/tests.rs +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -14,11 +14,12 @@ use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use serde_json::json; use tempfile::TempDir; -use super::config::CpexFilterConfig; -use super::filter::CpexFilter; -use crate::FilterAction; -use crate::filter::HttpFilter; -use crate::test_utils::{make_filter_context, make_request}; +use super::{config::CpexFilterConfig, filter::CpexFilter}; +use crate::{ + FilterAction, + filter::HttpFilter, + test_utils::{make_filter_context, make_request}, +}; // ===================================================================== // Fixtures @@ -183,10 +184,7 @@ fn build_filter(config_path: String) -> CpexFilter { fn config_parses_minimal_yaml() { let yaml = "config_path: /etc/praxis/cpex.yaml"; let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); - assert_eq!( - cfg.config_path, "/etc/praxis/cpex.yaml", - "config_path round-trips", - ); + assert_eq!(cfg.config_path, "/etc/praxis/cpex.yaml", "config_path round-trips",); } /// `config_path:` is mandatory — there's no default that would let @@ -377,10 +375,7 @@ config_path: /etc/praxis/cpex.yaml body_acces: read_write "; let res: Result = serde_yaml::from_str(yaml); - assert!( - res.is_err(), - "deny_unknown_fields must reject `body_acces` typo", - ); + assert!(res.is_err(), "deny_unknown_fields must reject `body_acces` typo",); let msg = format!("{}", res.unwrap_err()); assert!( msg.contains("body_acces") || msg.contains("unknown field"), @@ -465,7 +460,7 @@ async fn missing_mcp_metadata_rejects_when_required() { "config.missing_mcp_metadata", "violation code should name the missing metadata", ); - } + }, other => panic!("expected Reject(500); got {other:?}"), } } @@ -512,15 +507,15 @@ async fn missing_mcp_metadata_passes_when_not_required() { /// parse it the same way they parse upstream errors. #[test] fn mcp_error_envelope_has_expected_shape() { - use super::error::mcp_error_envelope_bytes; use cpex_core::error::PluginViolation; + use super::error::mcp_error_envelope_bytes; + let violation = PluginViolation::new("test.deny", "policy says no"); let id = serde_json::json!(42); let bytes = mcp_error_envelope_bytes(Some(&violation), &id); - let parsed: serde_json::Value = - serde_json::from_slice(&bytes).expect("envelope must be valid JSON"); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("envelope must be valid JSON"); assert_eq!(parsed["jsonrpc"], "2.0"); assert_eq!(parsed["id"], 42); @@ -563,9 +558,10 @@ fn mcp_error_envelope_handles_missing_violation() { /// short `code: reason` diagnostic. #[test] fn auth_rejection_shape_when_violation_present() { - use super::error::auth_rejection; use cpex_core::error::PluginViolation; + use super::error::auth_rejection; + let violation = PluginViolation::new("auth.invalid_token", "bad signature"); let rej = auth_rejection(Some(&violation)); assert_eq!(rej.status, 401); @@ -637,18 +633,19 @@ fn fit_to_original_length_passes_through_on_equal() { assert_eq!(out, new); } -/// On grow, the rewrite passes through and praxis ships the larger body. -/// This risks HTTP/1.1 framing desync (praxis can't recompute -/// `Content-Length` from the body phase yet) but dropping the rewrite -/// is worse for the deny-replacement case. The escape hatch logs a -/// warning via tracing; the test pins only the byte-equality behavior. +/// On grow, the body is truncated to exactly the original +/// `Content-Length`. The downstream response length is already committed +/// by the time `on_response_body` runs, so emitting more bytes would let +/// the overflow be parsed as the next response (a smuggling primitive). +/// Truncation corrupts the JSON but preserves HTTP/1.1 framing — the +/// safe failure mode. #[test] -fn fit_to_original_length_passes_through_on_grow() { +fn fit_to_original_length_truncates_on_grow() { use super::filter::fit_to_original_length; let new = bytes::Bytes::from_static(b"a much longer rewritten payload"); let out = fit_to_original_length(new.clone(), 4, "tools/call", "test"); - assert_eq!(out, new); - assert!(out.len() > 4, "grow path must not truncate"); + assert_eq!(out.len(), 4, "grow path must truncate to the original length"); + assert_eq!(&out[..], &new[..4], "truncation keeps the leading bytes"); } // ===================================================================== @@ -718,9 +715,10 @@ fn json_rpc_id_value_preserves_json_type() { /// part so APL `args.` predicates have something to read. #[test] fn build_content_for_method_tools_call() { - use super::json_rpc::build_content_for_method; use cpex_core::cmf::ContentPart; + use super::json_rpc::build_content_for_method; + let body = bytes::Bytes::from_static( br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"echo","arguments":{"text":"hi","n":7}}}"#, @@ -733,7 +731,7 @@ fn build_content_for_method_tools_call() { assert_eq!(content.tool_call_id, "corr-1"); assert_eq!(content.arguments.get("text"), Some(&serde_json::json!("hi"))); assert_eq!(content.arguments.get("n"), Some(&serde_json::json!(7))); - } + }, other => panic!("expected ToolCall; got {other:?}"), } } @@ -742,9 +740,10 @@ fn build_content_for_method_tools_call() { /// so route resolution and APL `resource.*` predicates work. #[test] fn build_content_for_method_resources_read() { - use super::json_rpc::build_content_for_method; use cpex_core::cmf::ContentPart; + use super::json_rpc::build_content_for_method; + let body = bytes::Bytes::from_static( br#"{"jsonrpc":"2.0","id":1,"method":"resources/read", "params":{"uri":"file:///etc/example"}}"#, @@ -755,7 +754,7 @@ fn build_content_for_method_resources_read() { ContentPart::ResourceRef { content } => { assert_eq!(content.uri, "file:///etc/example"); assert_eq!(content.resource_request_id, "corr-1"); - } + }, other => panic!("expected ResourceRef; got {other:?}"), } } @@ -777,15 +776,15 @@ fn build_content_for_method_unknown_method_yields_empty() { /// APL actually mutated. #[test] fn reserialize_tools_call_round_trips_with_mutated_args() { - use super::json_rpc::reserialize_json_rpc_body; use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use super::json_rpc::reserialize_json_rpc_body; + let original = bytes::Bytes::from_static( br#"{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"echo","arguments":{"a":1}}}"#, ); - let mut new_args: std::collections::HashMap = - std::collections::HashMap::new(); + let mut new_args: std::collections::HashMap = std::collections::HashMap::new(); new_args.insert("a".to_owned(), serde_json::json!("[REDACTED]")); let message = Message::with_content( Role::User, @@ -798,8 +797,7 @@ fn reserialize_tools_call_round_trips_with_mutated_args() { }, }], ); - let new_bytes = - reserialize_json_rpc_body(&original, "tools/call", &message).expect("rewrite Some"); + let new_bytes = reserialize_json_rpc_body(&original, "tools/call", &message).expect("rewrite Some"); let parsed: serde_json::Value = serde_json::from_slice(&new_bytes).expect("valid JSON"); assert_eq!(parsed["jsonrpc"], "2.0"); assert_eq!(parsed["id"], 1); @@ -814,9 +812,10 @@ fn reserialize_tools_call_round_trips_with_mutated_args() { /// `isError` flag round-trips. #[test] fn build_response_content_for_method_text_fallback() { - use super::json_rpc::build_response_content_for_method; use cpex_core::cmf::ContentPart; + use super::json_rpc::build_response_content_for_method; + let body = bytes::Bytes::from_static( br#"{"jsonrpc":"2.0","id":1,"result":{ "content":[{"type":"text","text":"{\"k\":\"v\"}"}], @@ -828,7 +827,7 @@ fn build_response_content_for_method_text_fallback() { ContentPart::ToolResult { content } => { assert!(!content.is_error); assert_eq!(content.content, serde_json::json!({"k":"v"})); - } + }, other => panic!("expected ToolResult; got {other:?}"), } } @@ -837,9 +836,10 @@ fn build_response_content_for_method_text_fallback() { /// when present (newer MCP shape). #[test] fn build_response_content_for_method_prefers_structured_content() { - use super::json_rpc::build_response_content_for_method; use cpex_core::cmf::ContentPart; + use super::json_rpc::build_response_content_for_method; + let body = bytes::Bytes::from_static( br#"{"jsonrpc":"2.0","id":1,"result":{ "content":[{"type":"text","text":"ignored"}], @@ -852,11 +852,105 @@ fn build_response_content_for_method_prefers_structured_content() { ContentPart::ToolResult { content } => { assert!(content.is_error); assert_eq!(content.content, serde_json::json!({"hi":"there"})); - } + }, other => panic!("expected ToolResult; got {other:?}"), } } +/// Response-side: when `result.content` has MULTIPLE text blocks and no +/// `structuredContent`, every block must end up in APL's view — not just +/// the first. Otherwise a later block carries data the policy never +/// inspected and the re-serializer never rewrites, leaking it. The +/// folded view exposes all blocks under `text`. +#[test] +fn build_response_content_for_method_folds_all_text_blocks() { + use cpex_core::cmf::ContentPart; + + use super::json_rpc::build_response_content_for_method; + + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{ + "content":[ + {"type":"text","text":"first secret"}, + {"type":"text","text":"second secret"} + ], + "isError":false}}"#, + ); + let parts = build_response_content_for_method("tools/call", "echo", "corr-1", &body); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolResult { content } => { + let text = content.content["text"].as_str().expect("text field present"); + assert!( + text.contains("first secret") && text.contains("second secret"), + "folded view must include every text block; got {text:?}", + ); + }, + other => panic!("expected ToolResult; got {other:?}"), + } +} + +/// Response-side emit: when APL mutates the result, the entire +/// `result.content` array is collapsed to a single canonical text block +/// holding the vetted payload. Any other blocks (extra text, non-text) +/// are dropped so nothing the policy didn't vet survives, and +/// `structuredContent` is mirrored when the original had it. +#[test] +fn reserialize_response_collapses_to_single_vetted_block() { + use cpex_core::cmf::{ContentPart, Message, Role, ToolResult}; + + use super::json_rpc::reserialize_json_rpc_response_body; + + let original = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"old one"},{"type":"text","text":"old two"},{"type":"image","data":"B64","mimeType":"image/png"}],"structuredContent":{"ssn":"555-12-3456"},"isError":false}}"#, + ); + let vetted = serde_json::json!({ "ssn": "[REDACTED]" }); + let message = Message::with_content( + Role::Assistant, + vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: String::new(), + tool_name: "echo".to_owned(), + content: vetted.clone(), + is_error: false, + }, + }], + ); + let out = reserialize_json_rpc_response_body(&original, "tools/call", &message).expect("Some"); + let parsed: serde_json::Value = serde_json::from_slice(&out).expect("valid JSON"); + let content = parsed["result"]["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1, "extra blocks must be dropped; got {content:?}"); + let inner: serde_json::Value = + serde_json::from_str(content[0]["text"].as_str().expect("text")).expect("vetted JSON"); + assert_eq!(inner, vetted, "emitted block must hold exactly the vetted value"); + assert_eq!( + parsed["result"]["structuredContent"], vetted, + "structuredContent mirrors vetted" + ); +} + +/// Fail-closed sizing: a deny envelope substituted on the +/// response-rewrite-overflow / identity-failure paths is fitted to the +/// committed `Content-Length` — never longer. Pins the composition the +/// filter relies on so an oversized rewrite can never become a framing +/// desync. +#[test] +fn deny_envelope_fits_committed_length() { + use cpex_core::error::PluginViolation; + + use super::{error::mcp_error_envelope_bytes, filter::fit_to_original_length}; + + let violation = PluginViolation::new("gateway.response_rewrite_overflow", "too large to fit"); + let envelope = mcp_error_envelope_bytes(Some(&violation), &serde_json::json!(1)); + let original_len = envelope.len() + 64; + let fitted = fit_to_original_length(envelope, original_len, "tools/call", "overflow"); + assert_eq!( + fitted.len(), + original_len, + "deny envelope must be padded to exactly the committed length", + ); +} + // ===================================================================== // on_request_body — CMF dispatch path (identity-only policy, no routes) // ===================================================================== @@ -907,7 +1001,7 @@ async fn on_request_body_continues_on_partial_chunks() { let mut ctx = make_filter_context(&req); let mut chunk = Some(bytes::Bytes::from_static(br#"{"jsonrpc":"2.0""#)); let action = filter - .on_request_body(&mut ctx, &mut chunk, /*end_of_stream=*/ false) + .on_request_body(&mut ctx, &mut chunk, /* end_of_stream= */ false) .await .expect("filter ran"); assert!( @@ -933,7 +1027,7 @@ fn on_response_body_in_read_only_is_a_no_op() { let mut ctx = make_filter_context(&req); let mut body = Some(bytes::Bytes::from_static(b"some upstream body")); let action = filter - .on_response_body(&mut ctx, &mut body, /*end_of_stream=*/ true) + .on_response_body(&mut ctx, &mut body, /* end_of_stream= */ true) .expect("hook ran"); assert!( matches!(action, FilterAction::Continue), @@ -956,7 +1050,7 @@ fn on_response_body_continues_on_partial_chunks() { let mut ctx = make_filter_context(&req); let mut chunk = Some(bytes::Bytes::from_static(b"partial")); let action = filter - .on_response_body(&mut ctx, &mut chunk, /*end_of_stream=*/ false) + .on_response_body(&mut ctx, &mut chunk, /* end_of_stream= */ false) .expect("hook ran"); assert!(matches!(action, FilterAction::Continue)); } @@ -976,29 +1070,19 @@ fn on_response_body_continues_on_partial_chunks() { #[test] #[allow(clippy::too_many_lines, reason = "test fixture construction")] fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { - use super::filter::attach_delegated_tokens; + use std::sync::Arc; + use chrono::{Duration, Utc}; - use cpex_core::extensions::container::Extensions; - use cpex_core::extensions::raw_credentials::{ - DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + use cpex_core::extensions::{ + container::Extensions, + raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, }; - use std::sync::Arc; + + use super::filter::attach_delegated_tokens; let expires = Utc::now() + Duration::hours(1); - let tok_a = RawDelegatedToken::new( - "token-a", - "Authorization", - "aud-a", - Vec::::new(), - expires, - ); - let tok_b = RawDelegatedToken::new( - "token-b", - "Authorization", - "aud-b", - Vec::::new(), - expires, - ); + let tok_a = RawDelegatedToken::new("token-a", "Authorization", "aud-a", Vec::::new(), expires); + let tok_b = RawDelegatedToken::new("token-b", "Authorization", "aud-b", Vec::::new(), expires); let key_a = DelegationKey { subject_id: "alice".to_owned(), audience: "aud-a".to_owned(), @@ -1039,29 +1123,19 @@ fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { #[test] #[allow(clippy::too_many_lines, reason = "test fixture construction")] fn attach_delegated_tokens_distinct_outbound_headers_all_attach() { - use super::filter::attach_delegated_tokens; + use std::sync::Arc; + use chrono::{Duration, Utc}; - use cpex_core::extensions::container::Extensions; - use cpex_core::extensions::raw_credentials::{ - DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + use cpex_core::extensions::{ + container::Extensions, + raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, }; - use std::sync::Arc; + + use super::filter::attach_delegated_tokens; let expires = Utc::now() + Duration::hours(1); - let tok_auth = RawDelegatedToken::new( - "token-auth", - "Authorization", - "aud-auth", - Vec::::new(), - expires, - ); - let tok_x = RawDelegatedToken::new( - "token-x", - "X-Upstream-Token", - "aud-x", - Vec::::new(), - expires, - ); + let tok_auth = RawDelegatedToken::new("token-auth", "Authorization", "aud-auth", Vec::::new(), expires); + let tok_x = RawDelegatedToken::new("token-x", "X-Upstream-Token", "aud-x", Vec::::new(), expires); let key_auth = DelegationKey { subject_id: "alice".to_owned(), audience: "aud-auth".to_owned(), diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index a25d2594..14b11623 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -6,6 +6,8 @@ pub(crate) mod http; mod tcp; +#[cfg(feature = "cpex")] +pub use http::CpexFilter; #[cfg(feature = "ai-inference")] pub use http::AnthropicMessagesFormatFilter; #[cfg(feature = "ai-inference")] @@ -28,8 +30,6 @@ pub use http::ResponseStoreFilter; pub use http::ResponseStoreRegistry; #[cfg(feature = "ai-inference")] pub use http::ResponsesFormatFilter; -#[cfg(feature = "cpex")] -pub use http::CpexFilter; pub use http::{ A2aFilter, AccessLogFilter, CircuitBreakerFilter, CompressionFilter, ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, ForwardedHeadersFilter, GrpcDetectionFilter, diff --git a/filter/src/lib.rs b/filter/src/lib.rs index e63ff2e0..fa832493 100644 --- a/filter/src/lib.rs +++ b/filter/src/lib.rs @@ -25,6 +25,8 @@ mod tcp_filter; pub use actions::{FilterAction, Rejection}; pub use any_filter::AnyFilter; pub use body::{BodyAccess, BodyBuffer, BodyBufferOverflow, BodyCapabilities, BodyMode}; +#[cfg(feature = "cpex")] +pub use builtins::CpexFilter; #[cfg(feature = "ai-inference")] pub use builtins::AnthropicMessagesFormatFilter; #[cfg(feature = "ai-inference")] @@ -43,8 +45,6 @@ pub use builtins::PromptEnrichFilter; pub use builtins::ResponseStoreRegistry; #[cfg(feature = "ai-inference")] pub use builtins::ResponsesFormatFilter; -#[cfg(feature = "cpex")] -pub use builtins::CpexFilter; pub use builtins::{ CircuitBreakerFilter, ContainsValue, CredentialInjectionFilter, DisallowedOriginMode, GuardrailsAction, GuardrailsFilter, LoadBalancerFilter, PiiKind, RateLimitMode, RedirectStatus, RouterFilter, RuleTargetKind, diff --git a/tests/integration/tests/suite/examples/cpex.rs b/tests/integration/tests/suite/examples/cpex.rs index 695c4d6c..c76b64e8 100644 --- a/tests/integration/tests/suite/examples/cpex.rs +++ b/tests/integration/tests/suite/examples/cpex.rs @@ -22,8 +22,7 @@ use std::collections::HashMap; use praxis_core::config::Config; use praxis_test_utils::{ - example_config_path, free_port, http_send, parse_status, patch_yaml, start_backend_with_shutdown, - start_proxy, + example_config_path, free_port, http_send, parse_status, patch_yaml, start_backend_with_shutdown, start_proxy, }; // ----------------------------------------------------------------------------- @@ -38,16 +37,12 @@ fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { let praxis_yaml_path = example_config_path("security/cpex.yaml"); let policy_yaml_path = example_config_path("security/cpex-policy.yaml"); - let raw = std::fs::read_to_string(&praxis_yaml_path) - .unwrap_or_else(|e| panic!("read {praxis_yaml_path}: {e}")); + let raw = std::fs::read_to_string(&praxis_yaml_path).unwrap_or_else(|e| panic!("read {praxis_yaml_path}: {e}")); // The example uses a workspace-relative path for the policy file // because that's what an operator would write. The integration // test rewrites it to an absolute path so the filter resolves it // regardless of the test's working directory. - let with_policy = raw.replace( - "examples/configs/security/cpex-policy.yaml", - &policy_yaml_path, - ); + let with_policy = raw.replace("examples/configs/security/cpex-policy.yaml", &policy_yaml_path); let patched = patch_yaml(&with_policy, proxy_port, &port_map); Config::from_yaml(&patched).unwrap_or_else(|e| panic!("parse security/cpex.yaml: {e}")) } @@ -60,10 +55,7 @@ fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { fn cpex_example_missing_authorization_rejects_401() { let backend_guard = start_backend_with_shutdown("ok"); let proxy_port = free_port(); - let config = load_cpex_example( - proxy_port, - HashMap::from([("127.0.0.1:3000", backend_guard.port())]), - ); + let config = load_cpex_example(proxy_port, HashMap::from([("127.0.0.1:3000", backend_guard.port())])); let proxy = start_proxy(&config); // POST with a well-formed MCP body but no Authorization header. From 532b187cc844ca1c66e6742a7a90ce205dc99982 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 17 Jun 2026 01:38:32 -0400 Subject: [PATCH 03/14] feat(cpex): add CEL PDP, session taint, and filter docs 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 --- Cargo.lock | 112 +++++++-- Cargo.toml | 25 +- filter/Cargo.toml | 2 + .../src/builtins/http/security/cpex/README.md | 223 +++++++++++++++++ .../builtins/http/security/cpex/factories.rs | 11 +- .../src/builtins/http/security/cpex/filter.rs | 15 ++ .../src/builtins/http/security/cpex/tests.rs | 225 ++++++++++++++++++ 7 files changed, 577 insertions(+), 36 deletions(-) create mode 100644 filter/src/builtins/http/security/cpex/README.md diff --git a/Cargo.lock b/Cargo.lock index a606c8d3..2b93d18f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -140,8 +157,8 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "async-trait", "chrono", @@ -154,8 +171,8 @@ dependencies = [ [[package]] name = "apl-cmf" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "apl-core", "cpex-core", @@ -164,8 +181,8 @@ dependencies = [ [[package]] name = "apl-core" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "async-trait", "cpex-orchestration", @@ -179,8 +196,8 @@ dependencies = [ [[package]] name = "apl-cpex" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "apl-cmf", "apl-core", @@ -196,8 +213,8 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "apl-core", "async-trait", @@ -216,8 +233,8 @@ dependencies = [ [[package]] name = "apl-identity-jwt" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "apl-core", "async-trait", @@ -237,8 +254,8 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "apl-core", "async-trait", @@ -246,14 +263,30 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "stacker", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "apl-pdp-cel" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +dependencies = [ + "apl-core", + "async-trait", + "cel", + "serde", + "serde_json", + "serde_yaml", "thiserror 2.0.18", "tracing", ] [[package]] name = "apl-pii-scanner" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "async-trait", "cpex-core", @@ -497,6 +530,12 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bit-set" version = "0.8.0" @@ -727,6 +766,22 @@ dependencies = [ "smol_str", ] +[[package]] +name = "cel" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "pastey", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -882,8 +937,8 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex-core" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "arc-swap", "async-trait", @@ -905,8 +960,8 @@ dependencies = [ [[package]] name = "cpex-orchestration" -version = "0.1.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0ffced4#0ffced4f655edc1eee36c15989f6fd6ea59c5043" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" dependencies = [ "futures", "tokio", @@ -2494,6 +2549,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2732,6 +2796,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2947,6 +3017,7 @@ dependencies = [ "apl-delegator-oauth", "apl-identity-jwt", "apl-pdp-cedar-direct", + "apl-pdp-cel", "apl-pii-scanner", "async-trait", "bytes", @@ -4837,7 +4908,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 95da348a..c1b038b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,17 +34,18 @@ license = "MIT" repository = "https://github.com/praxis-proxy/praxis" [workspace.dependencies] -# CPEX runtime + APL plugins, all pinned to v0.2.0-ffi.test.7. Version -# fields and rev bump together when contextforge-org cuts the v0.2.0 -# release. -apl-audit-logger = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-cmf = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-core = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-cpex = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-delegator-oauth = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-identity-jwt = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-pdp-cedar-direct = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } -apl-pii-scanner = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +# CPEX runtime + APL plugins, all pinned to the same cpex `dev` rev +# (post-CEL-merge). Version fields and rev bump together when +# contextforge-org cuts the v0.2.0 release. +apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } @@ -52,7 +53,7 @@ bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } dashmap = "6.2.1" clap = { version = "4.6.1", features = ["derive"] } -cpex-core = { version = "0.1.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0ffced4" } +cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } criterion = { version = "0.8.2", features = ["async_tokio"] } futures = "0.3.32" h2 = "0.4.15" diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 1117f0fc..0a491976 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -28,6 +28,7 @@ cpex = [ "dep:apl-delegator-oauth", "dep:apl-identity-jwt", "dep:apl-pdp-cedar-direct", + "dep:apl-pdp-cel", "dep:apl-pii-scanner", "dep:cpex-core", "dep:tokio", @@ -46,6 +47,7 @@ apl-cpex = { workspace = true, optional = true } apl-delegator-oauth = { workspace = true, optional = true } apl-identity-jwt = { workspace = true, optional = true } apl-pdp-cedar-direct = { workspace = true, optional = true } +apl-pdp-cel = { workspace = true, optional = true } apl-pii-scanner = { workspace = true, optional = true } async-trait = { workspace = true } bytes = { workspace = true } diff --git a/filter/src/builtins/http/security/cpex/README.md b/filter/src/builtins/http/security/cpex/README.md new file mode 100644 index 00000000..29983426 --- /dev/null +++ b/filter/src/builtins/http/security/cpex/README.md @@ -0,0 +1,223 @@ +# cpex filter + +The `cpex` filter embeds the [CPEX](https://github.com/contextforge-org/cpex) +policy runtime inside a Praxis HTTP filter. It resolves identity, evaluates +policies based on routes, consults a PDP (Cedar or CEL), mints delegated tokens, +and run actions (e.g., scans for PII, emits audit records, tracks session taint, +and rewrites request and response bodies). Everything runs in-process: the CPEX +runtime are Rust crates linked into the proxy. + +It is feature-gated. Build with `--features cpex` to compile and register it. + +## Why use this filter + +A PDP or authorization rules engine answers one question: given this input, is the +action allowed? That verdict still has to be wired into something. Real authorization +resolves identity first, often consults more than one engine, mints a token for +the downstream call, strips sensitive fields from the payload, and writes an +audit record, in the right order with the right short-circuits. That +orchestration is normally bespoke code in every gateway. + +CPEX makes the orchestration declarative. A policy is a per-entity chain of +steps. The PDP is one step in the chain, and the steps around it express the +rest: cheap predicates run first, the PDP runs only for requests +that clear them, `delegate(...)` mints a downstream-scoped token, `redact(...)` +rewrites a field on the wire, `run(...)` invokes a guardrail or audit plugin, +and `taint(...)` records a session label that a later request can act on. + +## Where it sits in the chain + +The filter consumes the metadata Praxis's built-in `mcp` filter produces, so the +`mcp` filter must run before it: + +```text +mcp -> cpex -> router -> load_balancer +``` + +`mcp` parses the JSON-RPC body and writes `mcp.method` and `mcp.name` into filter +metadata. `cpex` reads that metadata to pick the matching policy route. With +`require_mcp_metadata: true` (the default) a request that reaches `cpex` without +`mcp.method` is rejected, which catches a chain that is missing `mcp` or has it +ordered after `cpex`. + +## Configuration + +The filter's fields sit directly under the `- filter:` entry. There is no +`config:` wrapper. + +```yaml +filters: + - filter: cpex + config_path: /etc/praxis/cpex.yaml # required + body_access: read_write # optional; default read_only + require_mcp_metadata: true # optional; default true + init_timeout_secs: 30 # optional; default 30 +``` + +| Field | Default | Purpose | +|---|---|---| +| `config_path` | required | Path to the CPEX policy document (the YAML below). Read once at startup; a parse or plugin-init error fails the build. | +| `body_access` | `read_only` | `read_only` buffers the body for inspection and discards mutations. `read_write` re-serializes field mutations (`redact`, `assign`) back into the request and response. | +| `require_mcp_metadata` | `true` | Reject any request that reaches the filter without `mcp.method` metadata. Set `false` only to front non-MCP traffic for identity-only enforcement. | +| `init_timeout_secs` | `30` | Time budget for `PluginManager::initialize` at startup (identity plugins fetch JWKS over HTTPS). On expiry the build fails fast. | + +## The policy document + +`config_path` points at the CPEX policy. It has three parts: a `plugins` toolbox, +a `global` block, and a `routes` block. The routes are the policy; everything +else is what they pull from. Each route's `policy` is an ordered list of APL +(Authorization Policy Logic) steps. + +```yaml +plugins: + - name: jwt-user # identity/jwt: validates X-User-Token + - name: jwt-client # identity/jwt: validates Authorization + - name: workday-oauth # delegator/oauth: RFC 8693 token exchange + - name: pii-scan # validator/pii-scan: PII detection + - name: audit-log # audit/logger: structured audit records + +global: + identity: [jwt-user, jwt-client] + pdp: + - kind: cel # inline CEL expressions; use cedar-direct for Cedar + +routes: + - tool: get_compensation + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" + - "run(audit-log)" + args: + ssn: "str | redact(!perm.view_ssn)" + result: + ssn: "str | redact(!perm.view_ssn)" + + - tool: search_repos + policy: + - "require(team.engineering)" + - cel: + expr: | + has(role.engineer) && role.engineer && args.visibility == "internal" + on_deny: + - "deny('engineering may read internal repos only', 'cel.policy_denied')" +``` + +### APL step vocabulary + +| Step | Effect | +|---|---| +| `require(predicate)` | Deny unless the predicate holds. | +| `: deny('reason', 'code')` | Deny with a reason and violation code when the predicate holds. | +| `cedar: { ... }` / `cel: { expr: ... }` | Consult the registered PDP. `on_allow` / `on_deny` attach reactions. | +| `delegate(plugin, target:, audience:, permissions:)` | Mint an audience-scoped token (RFC 8693) and attach it as an upstream header. | +| `run(name)` | Invoke a named plugin (PII scan, audit). `plugin(name)` is the same step. | +| `taint(label, session)` | Record a session label. See Sessions and taint. | +| `args.: "... \| redact(...) \| mask(n)"` | Rewrite a request argument (needs `body_access: read_write`). | +| `result.: "... \| redact(...)"` | Rewrite a response field on the way back. | + +Steps run in order and the chain short-circuits on the first deny. Order +deliberately: place `run(audit-log)` before a step that may deny so the attempt +is recorded. + +### PDP backends + +The filter registers two PDP factories: `cedar-direct` (Cedar policy sets) and +`cel` (inline CEL boolean expressions). A route selects one with a `cedar:` or +`cel:` step; the global `pdp` block declares which engine is configured. Both are +compiled into the same binary. + +The example above uses CEL. The Cedar form declares a policy set in the global +`pdp` block and calls it from a `cedar:` step, passing the resource built from +the request: + +```yaml +global: + pdp: + - kind: cedar-direct + policy_text: | + permit(principal, action == Action::"read", resource is Repo) + when { principal.roles.contains("engineer") && resource.visibility == "internal" }; + +routes: + - tool: search_repos + policy: + - "require(team.engineering)" + - cedar: + action: 'Action::"read"' + resource: + type: Repo + id: ${args.repo_name} + attributes: + visibility: ${args.visibility} +``` + +CEL reads request attributes inline (`args.visibility`); Cedar takes them as an +explicit `resource`. Both reach the same allow or deny. + +## Identity + +Each `identity/jwt` plugin reads its own configured header (for example +`Authorization` for the client, `X-User-Token` for the user) and validates the +JWT against the issuer's live JWKS. One request can carry several identities at +once. The filter runs an early identity gate in the request phase: a request with +no valid token is rejected with HTTP 401 before the body is buffered. + +## Sessions and taint + +`taint(label, session)` records a label that persists across requests in the same +session. A later route reads it with `security.labels contains "label"` and acts +on it. This is a cross-tool, cross-request data-flow control: reading a secret in +one call can block sending mail in a later call. + +The session is identified by the `X-Session-Id` request header. The filter maps +it to `agent.session_id`, and CPEX binds it to the resolved subject as +`H(subject : session_id)`. The same session id under a different subject is a +different bucket, so taint never crosses principals. When the header is absent, +CPEX derives a session id from identity instead. + +The session store is in-memory and per process. Labels reset when the proxy +restarts or hot-reloads. + +## Request and response phases + +- Request phase: after the full body is buffered, the filter dispatches the + pre-invoke CMF hook for the route's entity. A deny becomes a rejection. On + allow, delegated tokens are attached as upstream headers, and with + `body_access: read_write` any mutated arguments are written back into the body. +- Response phase: the filter dispatches the post-invoke hook. `result.` + redactions run here, so a value the backend returns unsolicited is still + stripped for a caller without the permission. A post-phase deny replaces the + response body with a JSON-RPC error envelope. + +The response status and headers are already committed by the time the body phase +runs, so a rewritten response body is fitted to the original Content-Length: it +is padded with trailing whitespace when shorter (JSON parsers ignore it), and a +rewrite that would grow the body fails closed to a length-fitting deny envelope +rather than desyncing HTTP framing. + +## Decisions and denials + +| Outcome | Wire shape | +|---|---| +| Identity / transport failure | HTTP 401, `WWW-Authenticate: Bearer`, `X-Cpex-Violation: `. | +| Policy deny (PDP, predicate, PII, taint, delegation) | HTTP 200 with a JSON-RPC error envelope (`code -32001`), `X-Cpex-Violation: `. Per the MCP Tools spec, gateway denials are JSON-RPC errors, not HTTP 4xx. | +| Missing `mcp.method` metadata | HTTP 500 (server-side misconfiguration). | + +`X-Cpex-Violation` echoes the violation code (for example `apl.policy`, +`cedar.default_deny`, `pii.detected`, `session_tainted_secret`) so audit and +access-log pipelines can classify denials without parsing the body. + +## Runtime requirements + +The response phase drives async work with `block_in_place`, which requires a +multi-threaded tokio runtime. Run the proxy with `work_stealing: true`. On a +current-thread runtime the filter rejects every request with a clear error rather +than panicking mid-response. + +## See also + +- `examples/configs/security/cpex.yaml` for a runnable filter config. +- The CPEX HR demo in the [praxis-demos](https://github.com/praxis-proxy/demos) repository for an end-to-end walkthrough + (identity, Cedar and CEL PDPs, delegation, redaction, PII scanning, session + taint) with the Bob, Eve, and Alice personas. diff --git a/filter/src/builtins/http/security/cpex/factories.rs b/filter/src/builtins/http/security/cpex/factories.rs index 704809f1..81d1192c 100644 --- a/filter/src/builtins/http/security/cpex/factories.rs +++ b/filter/src/builtins/http/security/cpex/factories.rs @@ -7,10 +7,12 @@ use std::sync::Arc; use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_LOGGER_KIND}; +use apl_core::step::PdpFactory; use apl_cpex::{AplOptions, DispatchCache, MemorySessionStore, register_apl}; use apl_delegator_oauth::{KIND as OAUTH_DELEGATOR_KIND, OAuthDelegatorFactory}; use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; use apl_pdp_cedar_direct::CedarDirectPdpFactory; +use apl_pdp_cel::CelPdpFactory; use apl_pii_scanner::{KIND as PII_SCANNER_KIND, PiiScannerFactory}; use cpex_core::manager::PluginManager; @@ -49,16 +51,19 @@ pub(super) fn register_builtin_factories(mgr: &Arc) { /// than leaking them to every predicate / PDP / step in the same /// route. /// -/// Ships the `cedar-direct` PDP factory by default; alternative PDPs -/// (OPA, Cedarling, future engines) slot in similarly. +/// Ships the `cedar-direct` and `cel` PDP factories; a route's `cedar:` +/// or `cel:` step selects which one runs. Alternative PDPs (OPA, +/// Cedarling, future engines) slot in similarly. pub(super) fn register_apl_visitor(mgr: &Arc) { + let pdp_factories: Vec> = + vec![Arc::new(CedarDirectPdpFactory::new()), Arc::new(CelPdpFactory::new())]; register_apl( mgr, AplOptions { dispatch_cache: Arc::new(DispatchCache::new()), session_store: Arc::new(MemorySessionStore::new()), pdps: Vec::new(), - pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], + pdp_factories, base_capabilities: None, }, ); diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs index 062b851b..78bd83a3 100644 --- a/filter/src/builtins/http/security/cpex/filter.rs +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -268,6 +268,21 @@ impl CpexFilter { meta.entity_name = Some(entity_name.to_owned()); ext.meta = Some(Arc::new(meta)); + // Thread a per-conversation session id from the `X-Session-Id` + // request header into `agent.session_id`. cpex's session resolver + // subject-scopes it (`H(subject:session_id)`), so session-scoped + // taint labels (`taint(label, session)`) persist across requests + // in the same conversation and stay isolated between principals. + // Absent header → cpex falls back to its identity-derived session. + if let Some(session_id) = Self::snapshot_headers(ctx) + .get("x-session-id") + .filter(|value| !value.is_empty()) + { + let mut agent = ext.agent.as_ref().map(|arc| (**arc).clone()).unwrap_or_default(); + agent.session_id = Some(session_id.clone()); + ext.agent = Some(Arc::new(agent)); + } + Ok(ext) } } diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs index 209203e1..fbf90d42 100644 --- a/filter/src/builtins/http/security/cpex/tests.rs +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -101,6 +101,156 @@ fn write_single_plugin_config() -> (TempDir, String) { (dir, path_str) } +/// Write a CPEX YAML that gates the `echo` tool through a CEL PDP step. +/// Single HS256 identity plugin (so `subject.id` resolves from the JWT +/// `sub`), a `kind: cel` PDP declared globally, and a route whose `cel:` +/// expression allows only `alice`. Exercises the `apl-pdp-cel` backend +/// end-to-end through the filter's CMF dispatch. +#[allow( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_cel_policy_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +global: + apl: + pdp: + - kind: cel +routes: + - tool: echo + apl: + policy: + - cel: + expr: | + subject.id == "alice" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Run a `tools/call` for the `echo` tool as `subject`, returning the +/// filter's body-phase action. Shared by the CEL allow/deny cases. +async fn dispatch_echo_as(filter: &CpexFilter, subject: &str) -> FilterAction { + let token = mint_jwt(&standard_claims(subject)); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", "echo"); + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#, + ); + filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran") +} + +/// Write a CPEX YAML demonstrating session tainting: a `read-secret` +/// tool taints the session, and a `send-out` tool denies when the +/// session carries that taint. Identity is the HS256 jwt plugin so +/// `subject.id` resolves; the taint persists in the in-process session +/// store keyed by the resolved session id. +#[allow( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] +fn write_taint_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +routes: + - tool: read-secret + apl: + policy: + - "taint(secret, session)" + - tool: send-out + apl: + policy: + - "security.labels contains \"secret\": deny('session accessed secret data', 'session_tainted_secret')" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + +/// Dispatch a `tools/call` for `tool` as `subject` with the given +/// `X-Session-Id`. Returns the filter's body-phase action. Threads the +/// session header so cpex's session-scoped taint store can persist / +/// hydrate labels across calls. +async fn dispatch_tool_session(filter: &CpexFilter, subject: &str, tool: &str, session_id: &str) -> FilterAction { + let token = mint_jwt(&standard_claims(subject)); + let mut req = make_request(Method::POST, "/"); + req.headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("header value"), + ); + req.headers.insert( + "X-Session-Id", + HeaderValue::from_str(session_id).expect("session header"), + ); + let mut ctx = make_filter_context(&req); + ctx.set_metadata("mcp.method", "tools/call"); + ctx.set_metadata("mcp.name", tool); + let body = bytes::Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"t","arguments":{}}}"#, + ); + filter + .on_request_body(&mut ctx, &mut Some(body), true) + .await + .expect("filter ran") +} + /// Write a CPEX YAML with two identity plugins, each reading its own /// header. Demonstrates the multi-source agentic identity story PR1 /// targets — one request can carry user + agent JWTs simultaneously, @@ -990,6 +1140,81 @@ async fn on_request_body_dispatches_cmf_when_metadata_present() { ); } +/// A `cel:` route step gates the call through the `apl-pdp-cel` backend. +/// `alice` satisfies `subject.id == "alice"` → Allow (`BodyDone`); any +/// other subject fails the predicate → fail-closed Deny (`Reject`). +/// Proves praxis registers `CelPdpFactory` and the CEL PDP decision +/// flows through CMF dispatch alongside Cedar. +#[tokio::test(flavor = "multi_thread")] +async fn cel_route_allows_matching_subject_and_denies_others() { + let (_dir, path) = write_cel_policy_config(); + let filter = build_filter(path); + + let allow = dispatch_echo_as(&filter, "alice").await; + assert!( + matches!(allow, FilterAction::BodyDone), + "alice satisfies the CEL predicate; expected BodyDone, got {allow:?}", + ); + + let deny = dispatch_echo_as(&filter, "eve").await; + assert!( + matches!(deny, FilterAction::Reject(_)), + "eve fails the CEL predicate; expected Reject, got {deny:?}", + ); +} + +/// Session tainting end-to-end through the filter: reading the secret +/// taints the session (`taint(secret, session)`), and a later call in +/// the SAME session is denied (`security.labels contains "secret"`). A +/// DIFFERENT session id is unaffected — taint is session-scoped. Proves +/// the `X-Session-Id` → `agent.session_id` wiring + the cpex session +/// store's hydrate/persist round-trip across requests. +#[tokio::test(flavor = "multi_thread")] +async fn session_taint_persists_and_denies_within_the_same_session() { + let (_dir, path) = write_taint_config(); + let filter = build_filter(path); + + let taint = dispatch_tool_session(&filter, "alice", "read-secret", "sess-1").await; + assert!( + matches!(taint, FilterAction::BodyDone), + "tainting call should pass; got {taint:?}", + ); + + let denied = dispatch_tool_session(&filter, "alice", "send-out", "sess-1").await; + assert!( + matches!(denied, FilterAction::Reject(_)), + "send-out in the tainted session must be denied; got {denied:?}", + ); + + let clean = dispatch_tool_session(&filter, "alice", "send-out", "sess-2").await; + assert!( + matches!(clean, FilterAction::BodyDone), + "send-out in a fresh session must pass; got {clean:?}", + ); +} + +/// Cross-principal isolation: session taint is keyed by the resolved +/// subject, so the SAME `X-Session-Id` under a different subject is a +/// different bucket. `eve` taints `shared`, but `bob` reusing `shared` +/// is unaffected — `H(eve:shared) != H(bob:shared)`. +#[tokio::test(flavor = "multi_thread")] +async fn session_taint_is_isolated_across_principals() { + let (_dir, path) = write_taint_config(); + let filter = build_filter(path); + + let taint = dispatch_tool_session(&filter, "eve", "read-secret", "shared").await; + assert!( + matches!(taint, FilterAction::BodyDone), + "eve's tainting call should pass; got {taint:?}", + ); + + let bob = dispatch_tool_session(&filter, "bob", "send-out", "shared").await; + assert!( + matches!(bob, FilterAction::BodyDone), + "bob reusing eve's session id must NOT inherit her taint; got {bob:?}", + ); +} + /// Non-EOS chunks must pass through untouched — CMF dispatch waits /// for the full body so praxis's `mcp` filter has finished parsing /// and writing metadata. Pins the streaming-chunk fast path. From 6641259bf742b1d43bd489a9daedaa6067a870d6 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 18 Jun 2026 22:29:05 -0400 Subject: [PATCH 04/14] feat(cpex): add Valkey session-store backend 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 --- Cargo.lock | 208 ++++++++++++++++-- Cargo.toml | 21 +- filter/Cargo.toml | 2 + .../builtins/http/security/cpex/factories.rs | 10 +- .../src/builtins/http/security/cpex/tests.rs | 56 +++++ 5 files changed, 270 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b93d18f..43da74db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "async-trait", "chrono", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "apl-cmf" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-core", "cpex-core", @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "apl-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "async-trait", "cpex-orchestration", @@ -197,7 +197,7 @@ dependencies = [ [[package]] name = "apl-cpex" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-cmf", "apl-core", @@ -207,6 +207,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -214,7 +215,7 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-core", "async-trait", @@ -234,7 +235,7 @@ dependencies = [ [[package]] name = "apl-identity-jwt" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-core", "async-trait", @@ -255,7 +256,7 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-core", "async-trait", @@ -271,7 +272,7 @@ dependencies = [ [[package]] name = "apl-pdp-cel" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "apl-core", "async-trait", @@ -286,7 +287,7 @@ dependencies = [ [[package]] name = "apl-pii-scanner" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "async-trait", "cpex-core", @@ -297,6 +298,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "apl-session-valkey" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +dependencies = [ + "apl-cpex", + "async-trait", + "deadpool-redis", + "redis", + "serde", + "serde_yaml", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "ar_archive_writer" version = "0.5.2" @@ -315,6 +334,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "arrayvec" version = "0.5.2" @@ -403,6 +428,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -500,6 +536,15 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.22.1" @@ -890,6 +935,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -929,6 +988,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -938,7 +1007,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "arc-swap", "async-trait", @@ -961,7 +1030,7 @@ dependencies = [ [[package]] name = "cpex-orchestration" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=00a0f81c680f3749f049c1877447581a49994b6f#00a0f81c680f3749f049c1877447581a49994b6f" +source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" dependencies = [ "futures", "tokio", @@ -1171,6 +1240,36 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool", + "redis", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1358,6 +1457,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -2733,6 +2842,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ouroboros" version = "0.18.5" @@ -3019,6 +3134,7 @@ dependencies = [ "apl-pdp-cedar-direct", "apl-pdp-cel", "apl-pii-scanner", + "apl-session-valkey", "async-trait", "bytes", "chrono", @@ -3559,7 +3675,7 @@ dependencies = [ "log", "nix", "once_cell", - "openssl-probe", + "openssl-probe 0.1.6", "ouroboros", "parking_lot", "percent-encoding", @@ -3695,7 +3811,7 @@ dependencies = [ "quixotic-plecostomus-error", "ring", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "rustls-pemfile", "rustls-pki-types", "tokio-rustls", @@ -3855,6 +3971,35 @@ dependencies = [ "yasna", ] +[[package]] +name = "redis" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs 0.8.4", + "ryu", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4068,11 +4213,23 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -4189,7 +4346,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5911,6 +6081,12 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml_serde" version = "0.10.4" diff --git a/Cargo.toml b/Cargo.toml index c1b038b4..0fe127c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,15 +37,16 @@ repository = "https://github.com/praxis-proxy/praxis" # CPEX runtime + APL plugins, all pinned to the same cpex `dev` rev # (post-CEL-merge). Version fields and rev bump together when # contextforge-org cuts the v0.2.0 release. -apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } -apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +apl-session-valkey = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } @@ -53,7 +54,7 @@ bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } dashmap = "6.2.1" clap = { version = "4.6.1", features = ["derive"] } -cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "00a0f81c680f3749f049c1877447581a49994b6f" } +cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } criterion = { version = "0.8.2", features = ["async_tokio"] } futures = "0.3.32" h2 = "0.4.15" diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 0a491976..4c1a0bb5 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -30,6 +30,7 @@ cpex = [ "dep:apl-pdp-cedar-direct", "dep:apl-pdp-cel", "dep:apl-pii-scanner", + "dep:apl-session-valkey", "dep:cpex-core", "dep:tokio", ] @@ -49,6 +50,7 @@ apl-identity-jwt = { workspace = true, optional = true } apl-pdp-cedar-direct = { workspace = true, optional = true } apl-pdp-cel = { workspace = true, optional = true } apl-pii-scanner = { workspace = true, optional = true } +apl-session-valkey = { workspace = true, optional = true } async-trait = { workspace = true } bytes = { workspace = true } cpex-core = { workspace = true, optional = true } diff --git a/filter/src/builtins/http/security/cpex/factories.rs b/filter/src/builtins/http/security/cpex/factories.rs index 81d1192c..c23020b6 100644 --- a/filter/src/builtins/http/security/cpex/factories.rs +++ b/filter/src/builtins/http/security/cpex/factories.rs @@ -8,12 +8,13 @@ use std::sync::Arc; use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_LOGGER_KIND}; use apl_core::step::PdpFactory; -use apl_cpex::{AplOptions, DispatchCache, MemorySessionStore, register_apl}; +use apl_cpex::{AplOptions, DispatchCache, MemorySessionStore, SessionStoreFactory, register_apl}; use apl_delegator_oauth::{KIND as OAUTH_DELEGATOR_KIND, OAuthDelegatorFactory}; use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; use apl_pdp_cedar_direct::CedarDirectPdpFactory; use apl_pdp_cel::CelPdpFactory; use apl_pii_scanner::{KIND as PII_SCANNER_KIND, PiiScannerFactory}; +use apl_session_valkey::ValkeySessionStoreFactory; use cpex_core::manager::PluginManager; // ----------------------------------------------------------------------------- @@ -54,9 +55,15 @@ pub(super) fn register_builtin_factories(mgr: &Arc) { /// Ships the `cedar-direct` and `cel` PDP factories; a route's `cedar:` /// or `cel:` step selects which one runs. Alternative PDPs (OPA, /// Cedarling, future engines) slot in similarly. +/// +/// Ships the `valkey` session-store factory. The in-memory +/// `session_store` below stays the active backend unless a +/// `session_store: { kind: valkey, ... }` block selects the +/// Valkey-backed store for distributed, restart-durable taint labels. pub(super) fn register_apl_visitor(mgr: &Arc) { let pdp_factories: Vec> = vec![Arc::new(CedarDirectPdpFactory::new()), Arc::new(CelPdpFactory::new())]; + let session_store_factories: Vec> = vec![Arc::new(ValkeySessionStoreFactory::new())]; register_apl( mgr, AplOptions { @@ -64,6 +71,7 @@ pub(super) fn register_apl_visitor(mgr: &Arc) { session_store: Arc::new(MemorySessionStore::new()), pdps: Vec::new(), pdp_factories, + session_store_factories, base_capabilities: None, }, ); diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs index fbf90d42..76ce6c98 100644 --- a/filter/src/builtins/http/security/cpex/tests.rs +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -309,6 +309,52 @@ fn write_multi_source_config() -> (TempDir, String) { (dir, path_str) } +/// Write a CPEX YAML selecting the Valkey-backed session store via a +/// flat `global.session_store` block. The `valkey` factory connects +/// lazily (the pool dials on first request), so this config loads +/// without a running Valkey — it pins that the factory is registered +/// and the flat `session_store` block parses and resolves. +fn write_valkey_session_store_config() -> (TempDir, String) { + let dir = TempDir::new().expect("create tempdir"); + let cfg_path = dir.path().join("cpex.yaml"); + + let yaml = format!( + r#"plugins: + - name: jwt-user + kind: identity/jwt + hooks: + - identity.resolve + mode: sequential + priority: 10 + on_error: fail + config: + header: Authorization + trusted_issuers: + - issuer: "{TEST_ISSUER}" + audiences: ["{TEST_AUDIENCE}"] + algorithms: ["HS256"] + decoding_key: + kind: secret + secret: "{TEST_SECRET}" + leeway_seconds: 60 + claim_mapper: standard +global: + session_store: + kind: valkey + endpoint: localhost:6379 +routes: + - tool: read-secret + apl: + policy: + - "taint(secret, session)" +"# + ); + + std::fs::write(&cfg_path, yaml).expect("write cpex.yaml"); + let path_str = cfg_path.to_str().expect("utf8 path").to_owned(); + (dir, path_str) +} + /// Build a `CpexFilter` from a YAML config path. Defaults /// `require_mcp_metadata` to true so the test surface matches the /// production default; individual tests that want to test the @@ -360,6 +406,16 @@ async fn filter_constructs_from_valid_yaml() { let _filter = build_filter(path); } +/// A config selecting the Valkey session store (`global.session_store`, +/// flat form) loads without a running Valkey: the `valkey` factory is +/// registered and its pool dials lazily on first request. Proves the +/// factory wiring and that the flat `session_store` block resolves. +#[tokio::test(flavor = "multi_thread")] +async fn valkey_session_store_config_builds() { + let (_dir, path) = write_valkey_session_store_config(); + let _filter = build_filter(path); +} + /// A request with no `Authorization` header has no token for the JWT /// plugin to validate; the identity hook chain denies and the filter /// emits HTTP 401. From 63310cee8dad06d1d693f6ffcd6781ed892416f0 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 18 Jun 2026 23:12:53 -0400 Subject: [PATCH 05/14] build(cpex): pin cpex crates to the 0.2.0-alpha.3 release tag 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 --- Cargo.lock | 38 +++++++++++++++++++------------------- Cargo.toml | 28 ++++++++++++++-------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43da74db..6a1835f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "async-trait", "chrono", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "apl-cmf" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-core", "cpex-core", @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "apl-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "async-trait", "cpex-orchestration", @@ -197,7 +197,7 @@ dependencies = [ [[package]] name = "apl-cpex" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-cmf", "apl-core", @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-core", "async-trait", @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "apl-identity-jwt" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-core", "async-trait", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-core", "async-trait", @@ -272,7 +272,7 @@ dependencies = [ [[package]] name = "apl-pdp-cel" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-core", "async-trait", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "apl-pii-scanner" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "async-trait", "cpex-core", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "apl-session-valkey" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "apl-cpex", "async-trait", @@ -1007,7 +1007,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "arc-swap", "async-trait", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "cpex-orchestration" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062#ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" +source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" dependencies = [ "futures", "tokio", @@ -1432,7 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2744,7 +2744,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3610,7 +3610,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4188,7 +4188,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4914,7 +4914,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4923,7 +4923,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5714,7 +5714,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0fe127c9..e986ba6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,19 +34,19 @@ license = "MIT" repository = "https://github.com/praxis-proxy/praxis" [workspace.dependencies] -# CPEX runtime + APL plugins, all pinned to the same cpex `dev` rev -# (post-CEL-merge). Version fields and rev bump together when -# contextforge-org cuts the v0.2.0 release. -apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } -apl-session-valkey = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +# CPEX runtime + APL plugins, all pinned to the same cpex release tag. +# Bump the tag together across every crate when contextforge-org cuts a +# new release. +apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } +apl-session-valkey = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } @@ -54,7 +54,7 @@ bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } dashmap = "6.2.1" clap = { version = "4.6.1", features = ["derive"] } -cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "ef0439e2d1e4d4cd3cd841eb5628a3b7b9ecb062" } +cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } criterion = { version = "0.8.2", features = ["async_tokio"] } futures = "0.3.32" h2 = "0.4.15" From 516e6936abc24ad641c192ce79940bd4ecfd6940 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 00:15:14 -0400 Subject: [PATCH 06/14] refactor(cpex): depend on the single cpex facade crate 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 --- Cargo.lock | 53 +++++++------ Cargo.toml | 17 +--- filter/Cargo.toml | 26 +------ filter/src/builtins/http/security/cpex/cmf.rs | 2 +- .../src/builtins/http/security/cpex/error.rs | 2 +- .../builtins/http/security/cpex/factories.rs | 78 ------------------- .../src/builtins/http/security/cpex/filter.rs | 6 +- .../builtins/http/security/cpex/json_rpc.rs | 4 +- filter/src/builtins/http/security/cpex/mod.rs | 1 - .../src/builtins/http/security/cpex/tests.rs | 24 +++--- 10 files changed, 56 insertions(+), 157 deletions(-) delete mode 100644 filter/src/builtins/http/security/cpex/factories.rs diff --git a/Cargo.lock b/Cargo.lock index 6a1835f4..ec10694c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "async-trait", "chrono", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "apl-cmf" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-core", "cpex-core", @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "apl-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "async-trait", "cpex-orchestration", @@ -197,7 +197,7 @@ dependencies = [ [[package]] name = "apl-cpex" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-cmf", "apl-core", @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-core", "async-trait", @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "apl-identity-jwt" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-core", "async-trait", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-core", "async-trait", @@ -272,7 +272,7 @@ dependencies = [ [[package]] name = "apl-pdp-cel" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-core", "async-trait", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "apl-pii-scanner" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "async-trait", "cpex-core", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "apl-session-valkey" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "apl-cpex", "async-trait", @@ -1004,10 +1004,28 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpex" +version = "0.2.0" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +dependencies = [ + "apl-audit-logger", + "apl-cmf", + "apl-core", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pdp-cel", + "apl-pii-scanner", + "apl-session-valkey", + "cpex-core", +] + [[package]] name = "cpex-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "arc-swap", "async-trait", @@ -1030,7 +1048,7 @@ dependencies = [ [[package]] name = "cpex-orchestration" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?tag=0.2.0-alpha.3#b3faea1745588174c84334dccd0348e9fcf32504" +source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" dependencies = [ "futures", "tokio", @@ -3126,19 +3144,10 @@ dependencies = [ name = "praxis-proxy-filter" version = "0.3.1" dependencies = [ - "apl-audit-logger", - "apl-core", - "apl-cpex", - "apl-delegator-oauth", - "apl-identity-jwt", - "apl-pdp-cedar-direct", - "apl-pdp-cel", - "apl-pii-scanner", - "apl-session-valkey", "async-trait", "bytes", "chrono", - "cpex-core", + "cpex", "dashmap 6.2.1", "http", "jsonwebtoken", diff --git a/Cargo.toml b/Cargo.toml index e986ba6c..cd938e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,28 +34,15 @@ license = "MIT" repository = "https://github.com/praxis-proxy/praxis" [workspace.dependencies] -# CPEX runtime + APL plugins, all pinned to the same cpex release tag. -# Bump the tag together across every crate when contextforge-org cuts a -# new release. -apl-audit-logger = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-cmf = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-delegator-oauth = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-identity-jwt = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-pdp-cedar-direct = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-pdp-cel = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-pii-scanner = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } -apl-session-valkey = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } arc-swap = "1.9.1" async-trait = "0.1.89" benchmarks = { path = "benchmarks" } 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 = "0c199c899db14af6707d446a5e8eab7b74d55e7d", default-features = false, features = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] } clap = { version = "4.6.1", features = ["derive"] } -cpex-core = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", tag = "0.2.0-alpha.3" } criterion = { version = "0.8.2", features = ["async_tokio"] } +dashmap = "6.2.1" futures = "0.3.32" h2 = "0.4.15" http = "1.4.2" diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 4c1a0bb5..95d688a8 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -19,19 +19,10 @@ ai-inference = ["dep:secrecy", "dep:sqlx", "dep:tokio"] ext-proc = ["dep:praxis-proto", "dep:tonic", "dep:prost-wkt-types"] # CPEX policy filter — multi-source identity, APL routes, delegation, # PII detection, audit, and body rewriting. The CPEX runtime + the APL -# plugin set come in via the optional `apl-*` and `cpex-core` deps; -# `tokio` drives plugin init. +# plugin set come in via the optional `cpex` facade dep (which selects +# the plugins through its own features); `tokio` drives plugin init. cpex = [ - "dep:apl-audit-logger", - "dep:apl-core", - "dep:apl-cpex", - "dep:apl-delegator-oauth", - "dep:apl-identity-jwt", - "dep:apl-pdp-cedar-direct", - "dep:apl-pdp-cel", - "dep:apl-pii-scanner", - "dep:apl-session-valkey", - "dep:cpex-core", + "dep:cpex", "dep:tokio", ] @@ -42,18 +33,9 @@ ignored = ["praxis-proto", "prost-wkt-types", "tonic"] workspace = true [dependencies] -apl-audit-logger = { workspace = true, optional = true } -apl-core = { workspace = true, optional = true } -apl-cpex = { workspace = true, optional = true } -apl-delegator-oauth = { workspace = true, optional = true } -apl-identity-jwt = { workspace = true, optional = true } -apl-pdp-cedar-direct = { workspace = true, optional = true } -apl-pdp-cel = { workspace = true, optional = true } -apl-pii-scanner = { workspace = true, optional = true } -apl-session-valkey = { workspace = true, optional = true } async-trait = { workspace = true } bytes = { workspace = true } -cpex-core = { workspace = true, optional = true } +cpex = { workspace = true, optional = true } dashmap = { workspace = true } http = { workspace = true } percent-encoding = { workspace = true } diff --git a/filter/src/builtins/http/security/cpex/cmf.rs b/filter/src/builtins/http/security/cpex/cmf.rs index 42781d27..56b7c278 100644 --- a/filter/src/builtins/http/security/cpex/cmf.rs +++ b/filter/src/builtins/http/security/cpex/cmf.rs @@ -37,7 +37,7 @@ //! functions are the closed switch — anything not listed falls //! through to the identity-only path. -use cpex_core::cmf::constants::{ +use cpex::cpex_core::cmf::constants::{ ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, }; diff --git a/filter/src/builtins/http/security/cpex/error.rs b/filter/src/builtins/http/security/cpex/error.rs index 1f456907..9f8893a0 100644 --- a/filter/src/builtins/http/security/cpex/error.rs +++ b/filter/src/builtins/http/security/cpex/error.rs @@ -4,7 +4,7 @@ //! Maps CPEX `PluginViolation`s to praxis `Rejection`s. use bytes::Bytes; -use cpex_core::error::PluginViolation; +use cpex::cpex_core::error::PluginViolation; use crate::Rejection; diff --git a/filter/src/builtins/http/security/cpex/factories.rs b/filter/src/builtins/http/security/cpex/factories.rs deleted file mode 100644 index c23020b6..00000000 --- a/filter/src/builtins/http/security/cpex/factories.rs +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Praxis Contributors - -//! Plugin factory + PDP factory registrations bundled with the CPEX -//! filter. - -use std::sync::Arc; - -use apl_audit_logger::{AuditLoggerFactory, KIND as AUDIT_LOGGER_KIND}; -use apl_core::step::PdpFactory; -use apl_cpex::{AplOptions, DispatchCache, MemorySessionStore, SessionStoreFactory, register_apl}; -use apl_delegator_oauth::{KIND as OAUTH_DELEGATOR_KIND, OAuthDelegatorFactory}; -use apl_identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND}; -use apl_pdp_cedar_direct::CedarDirectPdpFactory; -use apl_pdp_cel::CelPdpFactory; -use apl_pii_scanner::{KIND as PII_SCANNER_KIND, PiiScannerFactory}; -use apl_session_valkey::ValkeySessionStoreFactory; -use cpex_core::manager::PluginManager; - -// ----------------------------------------------------------------------------- -// register_builtin_factories -// ----------------------------------------------------------------------------- - -/// Register the plugin factories this filter ships with: -/// -/// * `identity/jwt` — `apl-identity-jwt` (JWT identity resolver) -/// * `delegator/oauth` — `apl-delegator-oauth` (RFC 8693 token exchange) -/// * `validator/pii-scan` — `apl-pii-scanner` (regex-based PII detection) -/// * `audit/logger` — `apl-audit-logger` (structured audit emission) -/// -/// PDP factories (`cedar-direct`) wire via [`register_apl_visitor`] — -/// a different registration surface (`PdpFactory` vs `PluginFactory`). -pub(super) fn register_builtin_factories(mgr: &Arc) { - mgr.register_factory(JWT_KIND, Box::new(JwtIdentityFactory)); - mgr.register_factory(OAUTH_DELEGATOR_KIND, Box::new(OAuthDelegatorFactory)); - mgr.register_factory(PII_SCANNER_KIND, Box::new(PiiScannerFactory)); - mgr.register_factory(AUDIT_LOGGER_KIND, Box::new(AuditLoggerFactory)); -} - -// ----------------------------------------------------------------------------- -// register_apl_visitor -// ----------------------------------------------------------------------------- - -/// Wire the APL visitor onto the manager so it walks `routes:` blocks -/// at config-load time and installs `AplRouteHandler` annotations on -/// the hook table. The baseline is the visitor's default read-only -/// capability set (subject, roles, claims, etc.) — per-plugin caps -/// (`read_inbound_credentials` on the `OAuth` delegator, etc.) are -/// declared in the plugin's YAML `capabilities:` block and unioned -/// into the synthetic route handler by `apl-cpex`. This keeps -/// credential reads scoped to the plugin that declared the need rather -/// than leaking them to every predicate / PDP / step in the same -/// route. -/// -/// Ships the `cedar-direct` and `cel` PDP factories; a route's `cedar:` -/// or `cel:` step selects which one runs. Alternative PDPs (OPA, -/// Cedarling, future engines) slot in similarly. -/// -/// Ships the `valkey` session-store factory. The in-memory -/// `session_store` below stays the active backend unless a -/// `session_store: { kind: valkey, ... }` block selects the -/// Valkey-backed store for distributed, restart-durable taint labels. -pub(super) fn register_apl_visitor(mgr: &Arc) { - let pdp_factories: Vec> = - vec![Arc::new(CedarDirectPdpFactory::new()), Arc::new(CelPdpFactory::new())]; - let session_store_factories: Vec> = vec![Arc::new(ValkeySessionStoreFactory::new())]; - register_apl( - mgr, - AplOptions { - dispatch_cache: Arc::new(DispatchCache::new()), - session_store: Arc::new(MemorySessionStore::new()), - pdps: Vec::new(), - pdp_factories, - session_store_factories, - base_capabilities: None, - }, - ); -} diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs index 78bd83a3..990836be 100644 --- a/filter/src/builtins/http/security/cpex/filter.rs +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -25,7 +25,7 @@ use std::sync::{ use async_trait::async_trait; use bytes::Bytes; -use cpex_core::{ +use cpex::cpex_core::{ cmf::{CmfHook, Message, MessagePayload, Role}, error::{PluginError, PluginViolation}, hooks::Extensions, @@ -37,7 +37,6 @@ use super::{ cmf::{entity_for_mcp_method, entity_for_mcp_method_post}, config::{BodyAccessMode, CpexFilterConfig}, error::{VIOLATION_HEADER, auth_rejection, mcp_error_envelope_bytes, mcp_error_rejection}, - factories::{register_apl_visitor, register_builtin_factories}, json_rpc::{ build_content_for_method, build_response_content_for_method, json_rpc_id, json_rpc_id_value, reserialize_json_rpc_body, reserialize_json_rpc_response_body, @@ -134,8 +133,7 @@ impl CpexFilter { })?; let mgr = Arc::new(PluginManager::default()); - register_builtin_factories(&mgr); - register_apl_visitor(&mgr); + cpex::install_builtins(&mgr); mgr.load_config_yaml(&yaml) .map_err(|e: Box| -> FilterError { format!("cpex: load_config_yaml failed: {e}").into() })?; diff --git a/filter/src/builtins/http/security/cpex/json_rpc.rs b/filter/src/builtins/http/security/cpex/json_rpc.rs index 97e81d0f..f9e91d0d 100644 --- a/filter/src/builtins/http/security/cpex/json_rpc.rs +++ b/filter/src/builtins/http/security/cpex/json_rpc.rs @@ -21,7 +21,9 @@ //! when `body_access: read_write` is on. use bytes::Bytes; -use cpex_core::cmf::{ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult}; +use cpex::cpex_core::cmf::{ + ContentPart, Message, PromptRequest, ResourceReference, ResourceType, ToolCall, ToolResult, +}; // ----------------------------------------------------------------------------- // JSON-RPC id extraction diff --git a/filter/src/builtins/http/security/cpex/mod.rs b/filter/src/builtins/http/security/cpex/mod.rs index 6d319ecb..a4e0094a 100644 --- a/filter/src/builtins/http/security/cpex/mod.rs +++ b/filter/src/builtins/http/security/cpex/mod.rs @@ -9,7 +9,6 @@ mod cmf; mod config; mod error; -mod factories; mod filter; mod json_rpc; diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs index 76ce6c98..80ca3391 100644 --- a/filter/src/builtins/http/security/cpex/tests.rs +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -713,7 +713,7 @@ async fn missing_mcp_metadata_passes_when_not_required() { /// parse it the same way they parse upstream errors. #[test] fn mcp_error_envelope_has_expected_shape() { - use cpex_core::error::PluginViolation; + use cpex::cpex_core::error::PluginViolation; use super::error::mcp_error_envelope_bytes; @@ -764,7 +764,7 @@ fn mcp_error_envelope_handles_missing_violation() { /// short `code: reason` diagnostic. #[test] fn auth_rejection_shape_when_violation_present() { - use cpex_core::error::PluginViolation; + use cpex::cpex_core::error::PluginViolation; use super::error::auth_rejection; @@ -921,7 +921,7 @@ fn json_rpc_id_value_preserves_json_type() { /// part so APL `args.` predicates have something to read. #[test] fn build_content_for_method_tools_call() { - use cpex_core::cmf::ContentPart; + use cpex::cpex_core::cmf::ContentPart; use super::json_rpc::build_content_for_method; @@ -946,7 +946,7 @@ fn build_content_for_method_tools_call() { /// so route resolution and APL `resource.*` predicates work. #[test] fn build_content_for_method_resources_read() { - use cpex_core::cmf::ContentPart; + use cpex::cpex_core::cmf::ContentPart; use super::json_rpc::build_content_for_method; @@ -982,7 +982,7 @@ fn build_content_for_method_unknown_method_yields_empty() { /// APL actually mutated. #[test] fn reserialize_tools_call_round_trips_with_mutated_args() { - use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex::cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; use super::json_rpc::reserialize_json_rpc_body; @@ -1018,7 +1018,7 @@ fn reserialize_tools_call_round_trips_with_mutated_args() { /// `isError` flag round-trips. #[test] fn build_response_content_for_method_text_fallback() { - use cpex_core::cmf::ContentPart; + use cpex::cpex_core::cmf::ContentPart; use super::json_rpc::build_response_content_for_method; @@ -1042,7 +1042,7 @@ fn build_response_content_for_method_text_fallback() { /// when present (newer MCP shape). #[test] fn build_response_content_for_method_prefers_structured_content() { - use cpex_core::cmf::ContentPart; + use cpex::cpex_core::cmf::ContentPart; use super::json_rpc::build_response_content_for_method; @@ -1070,7 +1070,7 @@ fn build_response_content_for_method_prefers_structured_content() { /// folded view exposes all blocks under `text`. #[test] fn build_response_content_for_method_folds_all_text_blocks() { - use cpex_core::cmf::ContentPart; + use cpex::cpex_core::cmf::ContentPart; use super::json_rpc::build_response_content_for_method; @@ -1103,7 +1103,7 @@ fn build_response_content_for_method_folds_all_text_blocks() { /// `structuredContent` is mirrored when the original had it. #[test] fn reserialize_response_collapses_to_single_vetted_block() { - use cpex_core::cmf::{ContentPart, Message, Role, ToolResult}; + use cpex::cpex_core::cmf::{ContentPart, Message, Role, ToolResult}; use super::json_rpc::reserialize_json_rpc_response_body; @@ -1142,7 +1142,7 @@ fn reserialize_response_collapses_to_single_vetted_block() { /// desync. #[test] fn deny_envelope_fits_committed_length() { - use cpex_core::error::PluginViolation; + use cpex::cpex_core::error::PluginViolation; use super::{error::mcp_error_envelope_bytes, filter::fit_to_original_length}; @@ -1354,7 +1354,7 @@ fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { use std::sync::Arc; use chrono::{Duration, Utc}; - use cpex_core::extensions::{ + use cpex::cpex_core::extensions::{ container::Extensions, raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, }; @@ -1407,7 +1407,7 @@ fn attach_delegated_tokens_distinct_outbound_headers_all_attach() { use std::sync::Arc; use chrono::{Duration, Utc}; - use cpex_core::extensions::{ + use cpex::cpex_core::extensions::{ container::Extensions, raw_credentials::{DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken}, }; From 33f0cd118e108a268190897b090d3d6ca607df4e Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 00:19:37 -0400 Subject: [PATCH 07/14] chore: cleanup Signed-off-by: Frederico Araujo --- filter/Cargo.toml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/filter/Cargo.toml b/filter/Cargo.toml index 95d688a8..2898c73e 100644 --- a/filter/Cargo.toml +++ b/filter/Cargo.toml @@ -17,10 +17,6 @@ name = "praxis_filter" default = ["ai-inference"] ai-inference = ["dep:secrecy", "dep:sqlx", "dep:tokio"] ext-proc = ["dep:praxis-proto", "dep:tonic", "dep:prost-wkt-types"] -# CPEX policy filter — multi-source identity, APL routes, delegation, -# PII detection, audit, and body rewriting. The CPEX runtime + the APL -# plugin set come in via the optional `cpex` facade dep (which selects -# the plugins through its own features); `tokio` drives plugin init. cpex = [ "dep:cpex", "dep:tokio", @@ -61,11 +57,6 @@ tracing = { workspace = true } zeroize = { workspace = true } [dev-dependencies] -# `--features cpex` tests mint HS256 JWTs, write transient policy YAML -# files, and construct `RawDelegatedToken` instances directly (which -# requires a `chrono::DateTime` for `expires_at`). Always-on in -# test builds (overhead is small) so the `cpex` feature does not need -# to gate dev-deps. chrono = { workspace = true } jsonwebtoken = "9" tempfile = { workspace = true } From 5e6a58ff7a67d57e992cff89f601b43920af9b82 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 00:39:20 -0400 Subject: [PATCH 08/14] build(cpex): pin cpex to rev 702ab16 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 --- Cargo.lock | 48 ++++++++++++++++++++++++------------------------ Cargo.toml | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec10694c..c09a8402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -129,7 +129,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -158,7 +158,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apl-audit-logger" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "async-trait", "chrono", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "apl-cmf" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-core", "cpex-core", @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "apl-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "async-trait", "cpex-orchestration", @@ -197,7 +197,7 @@ dependencies = [ [[package]] name = "apl-cpex" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-cmf", "apl-core", @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "apl-delegator-oauth" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-core", "async-trait", @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "apl-identity-jwt" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-core", "async-trait", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "apl-pdp-cedar-direct" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-core", "async-trait", @@ -272,7 +272,7 @@ dependencies = [ [[package]] name = "apl-pdp-cel" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-core", "async-trait", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "apl-pii-scanner" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "async-trait", "cpex-core", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "apl-session-valkey" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-cpex", "async-trait", @@ -1007,7 +1007,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "apl-audit-logger", "apl-cmf", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "cpex-core" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "arc-swap", "async-trait", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "cpex-orchestration" version = "0.2.0" -source = "git+https://github.com/contextforge-org/cpex.git?rev=0c199c899db14af6707d446a5e8eab7b74d55e7d#0c199c899db14af6707d446a5e8eab7b74d55e7d" +source = "git+https://github.com/contextforge-org/cpex.git?rev=702ab163c64fe762318cb2bc5fb9b088869dd1af#702ab163c64fe762318cb2bc5fb9b088869dd1af" dependencies = [ "futures", "tokio", @@ -1450,7 +1450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2762,7 +2762,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3619,7 +3619,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4197,7 +4197,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4631,7 +4631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4805,7 +4805,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4923,7 +4923,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4932,7 +4932,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5723,7 +5723,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cd938e5e..8b8de349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ async-trait = "0.1.89" benchmarks = { path = "benchmarks" } bytes = "1.12.0" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } -cpex = { version = "0.2.0", git = "https://github.com/contextforge-org/cpex.git", rev = "0c199c899db14af6707d446a5e8eab7b74d55e7d", default-features = false, features = ["jwt", "oauth", "pii", "audit", "cedar", "cel", "valkey"] } +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"] } clap = { version = "4.6.1", features = ["derive"] } criterion = { version = "0.8.2", features = ["async_tokio"] } dashmap = "6.2.1" From 8d134b20b686a34f05cb11e00b91d82dfcdc4c42 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 01:01:52 -0400 Subject: [PATCH 09/14] fix(cpex): satisfy lints enabled on main after rebase 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 --- filter/src/builtins/http/security/cpex/filter.rs | 8 ++++++++ filter/src/builtins/mod.rs | 4 ++-- filter/src/lib.rs | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs index 990836be..478d7772 100644 --- a/filter/src/builtins/http/security/cpex/filter.rs +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -237,6 +237,10 @@ impl CpexFilter { /// raw-credentials, and stamps `MetaExtension.entity_type` / /// `entity_name` so route resolution in cpex-core picks the right /// route annotation. + #[expect( + clippy::large_stack_frames, + reason = "async handler holding large CMF Extensions across await points" + )] async fn build_cmf_extensions( &self, ctx: &HttpFilterContext<'_>, @@ -376,6 +380,10 @@ impl HttpFilter for CpexFilter { Ok(FilterAction::Continue) } + #[expect( + clippy::large_stack_frames, + reason = "async handler with multiple await points over large CMF types" + )] async fn on_request_body( &self, ctx: &mut HttpFilterContext<'_>, diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index 14b11623..2bd84697 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -6,8 +6,6 @@ pub(crate) mod http; mod tcp; -#[cfg(feature = "cpex")] -pub use http::CpexFilter; #[cfg(feature = "ai-inference")] pub use http::AnthropicMessagesFormatFilter; #[cfg(feature = "ai-inference")] @@ -18,6 +16,8 @@ pub use http::AnthropicStreamEventsFilter; pub use http::AnthropicToOpenaiFilter; #[cfg(feature = "ai-inference")] pub use http::AnthropicValidateFilter; +#[cfg(feature = "cpex")] +pub use http::CpexFilter; #[cfg(feature = "ai-inference")] pub use http::ModelToHeaderFilter; #[cfg(feature = "ai-inference")] diff --git a/filter/src/lib.rs b/filter/src/lib.rs index fa832493..a5e69922 100644 --- a/filter/src/lib.rs +++ b/filter/src/lib.rs @@ -25,8 +25,6 @@ mod tcp_filter; pub use actions::{FilterAction, Rejection}; pub use any_filter::AnyFilter; pub use body::{BodyAccess, BodyBuffer, BodyBufferOverflow, BodyCapabilities, BodyMode}; -#[cfg(feature = "cpex")] -pub use builtins::CpexFilter; #[cfg(feature = "ai-inference")] pub use builtins::AnthropicMessagesFormatFilter; #[cfg(feature = "ai-inference")] @@ -37,6 +35,8 @@ pub use builtins::AnthropicStreamEventsFilter; pub use builtins::AnthropicToOpenaiFilter; #[cfg(feature = "ai-inference")] pub use builtins::AnthropicValidateFilter; +#[cfg(feature = "cpex")] +pub use builtins::CpexFilter; #[cfg(feature = "ai-inference")] pub use builtins::OpenaiResponsesValidateFilter; #[cfg(feature = "ai-inference")] From 067325f4579ca87d504efd3ee292657ba4a2d98a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 01:24:06 -0400 Subject: [PATCH 10/14] fix(cpex): move policy doc to a test fixture so schema sweep passes 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 --- examples/configs/security/cpex.yaml | 21 ++++++++++--------- .../integration/fixtures}/cpex-policy.yaml | 6 +++++- .../integration/tests/suite/examples/cpex.rs | 12 +++++------ 3 files changed, 22 insertions(+), 17 deletions(-) rename {examples/configs/security => tests/integration/fixtures}/cpex-policy.yaml (78%) diff --git a/examples/configs/security/cpex.yaml b/examples/configs/security/cpex.yaml index dc9be6ab..14d6c9c3 100644 --- a/examples/configs/security/cpex.yaml +++ b/examples/configs/security/cpex.yaml @@ -9,12 +9,13 @@ # by praxis's built-in `mcp` filter, which MUST be ordered before it # in the chain. # -# `config_path` points at the CPEX policy YAML — plugins + routes — -# which the filter loads once at construction. The example policy file -# at `cpex-policy.yaml` declares a single HS256 identity plugin and -# nothing else, so the filter accepts any valid bearer JWT and passes -# the request through to the upstream. Real deployments add routes, -# Cedar PDPs, and delegators in the policy file. +# `config_path` points at the CPEX policy YAML (plugins + routes) that +# the filter loads once at construction. It is operator-supplied, not +# shipped here: a real policy carries your own issuers, secrets, routes, +# PDPs, and delegators. See the praxis-demos repo +# (`demos/cpex/cpex.yaml`) for a fully-featured policy, and +# `tests/integration/fixtures/cpex-policy.yaml` for the minimal +# single-HS256-identity-plugin policy the integration test loads. # # `require_mcp_metadata: true` (the default) fail-closes when the # `mcp` filter is missing or ordered after `cpex` — this guards @@ -41,9 +42,9 @@ # -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", # "params":{"name":"echo","arguments":{}}}' # -# The companion `cpex-policy.yaml` ships with a placeholder shared -# secret — replace it before doing anything beyond local -# experimentation. +# Supply your own policy document at `config_path` before running this +# example; the minimal test fixture uses a placeholder shared secret +# that must not be used beyond local experimentation. listeners: - name: default @@ -59,7 +60,7 @@ filter_chains: # mcp.method / mcp.name in filter_metadata. Must precede cpex. - filter: cpex - config_path: examples/configs/security/cpex-policy.yaml + config_path: /etc/praxis/cpex-policy.yaml # Fail-closed when mcp.method is missing. Set to `false` only # when intentionally fronting non-MCP traffic through cpex for # identity-only enforcement. diff --git a/examples/configs/security/cpex-policy.yaml b/tests/integration/fixtures/cpex-policy.yaml similarity index 78% rename from examples/configs/security/cpex-policy.yaml rename to tests/integration/fixtures/cpex-policy.yaml index be938be7..99872e8f 100644 --- a/examples/configs/security/cpex-policy.yaml +++ b/tests/integration/fixtures/cpex-policy.yaml @@ -1,4 +1,8 @@ -# CPEX policy document for the `cpex.yaml` praxis example. +# CPEX policy fixture for the `security/cpex.yaml` example integration +# test. The example itself points `config_path` at an operator-supplied +# deployment path; the test rewrites that to this file so the cpex +# filter constructs against a real policy without shipping one under +# examples/. # # Minimal shape: one HS256 JWT identity plugin, no routes. The cpex # filter resolves identity in on_request (deny on missing/invalid diff --git a/tests/integration/tests/suite/examples/cpex.rs b/tests/integration/tests/suite/examples/cpex.rs index c76b64e8..61559330 100644 --- a/tests/integration/tests/suite/examples/cpex.rs +++ b/tests/integration/tests/suite/examples/cpex.rs @@ -35,14 +35,14 @@ use praxis_test_utils::{ #[allow(clippy::needless_pass_by_value, reason = "callers construct the map inline")] fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { let praxis_yaml_path = example_config_path("security/cpex.yaml"); - let policy_yaml_path = example_config_path("security/cpex-policy.yaml"); + let policy_yaml_path = format!("{}/fixtures/cpex-policy.yaml", env!("CARGO_MANIFEST_DIR")); let raw = std::fs::read_to_string(&praxis_yaml_path).unwrap_or_else(|e| panic!("read {praxis_yaml_path}: {e}")); - // The example uses a workspace-relative path for the policy file - // because that's what an operator would write. The integration - // test rewrites it to an absolute path so the filter resolves it - // regardless of the test's working directory. - let with_policy = raw.replace("examples/configs/security/cpex-policy.yaml", &policy_yaml_path); + // The example points `config_path` at an operator-supplied + // deployment path (the policy is not shipped under examples/). The + // test rewrites it to the minimal in-repo fixture so the filter + // constructs regardless of the test's working directory. + let with_policy = raw.replace("/etc/praxis/cpex-policy.yaml", &policy_yaml_path); let patched = patch_yaml(&with_policy, proxy_port, &port_map); Config::from_yaml(&patched).unwrap_or_else(|e| panic!("parse security/cpex.yaml: {e}")) } From 09826209b38776b8b45e6c673fef9fc207faed11 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 01:35:58 -0400 Subject: [PATCH 11/14] chore: auto generate examples/README.md Signed-off-by: Frederico Araujo --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index f0691be6..92ad4f77 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,7 +129,7 @@ page. | File | Description | | ------ | ------------- | | [cors.yaml](configs/security/cors.yaml) | Spec-compliant CORS filter with preflight handling, origin validation, and credential support | -| [cpex.yaml](configs/security/cpex.yaml) | CPEX policy filter — multi-source JWT identity, APL routes, RFC 8693 delegation, PII scanning, audit, body rewriting (requires `--features cpex`) | +| [cpex.yaml](configs/security/cpex.yaml) | Embeds the CPEX policy runtime in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 OAuth 2.0 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting | | [csrf.yaml](configs/security/csrf.yaml) | Cross-site request forgery protection via origin validation | | [downstream-read-timeout.yaml](configs/security/downstream-read-timeout.yaml) | Protects against slow client attacks by limiting how long the proxy waits for data from downstream clients | | [forwarded-headers.yaml](configs/security/forwarded-headers.yaml) | Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host into upstream requests | From c932c863bbd41c97739418e6f5feb7e5f721477a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 19 Jun 2026 01:46:33 -0400 Subject: [PATCH 12/14] chore: generate filter docs Signed-off-by: Frederico Araujo --- docs/filters/http/security/cpex.md | 33 ++++++++++++++++++++++++++++++ docs/filters/reference.md | 1 + 2 files changed, 34 insertions(+) create mode 100644 docs/filters/http/security/cpex.md diff --git a/docs/filters/http/security/cpex.md b/docs/filters/http/security/cpex.md new file mode 100644 index 00000000..439e5404 --- /dev/null +++ b/docs/filters/http/security/cpex.md @@ -0,0 +1,33 @@ + + + +# `cpex` + +Configuration block for a `cpex` filter slot in a Praxis filter chain. + +Requires Cargo feature: `cpex`. + +## Configuration Notes + +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/cpex.yaml` for a runnable example. + +The referenced YAML is the CPEX 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 + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `config_path` | string | yes | Filesystem path to the CPEX YAML policy document. | +| `body_access` | `read_only` \| `read_write` | no | Body-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. | +| `require_mcp_metadata` | bool | no | Fail-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 praxis's built-in `mcp` filter, so its absence means either (a) the `mcp` filter is missing from the chain, or (b) it is ordered AFTER `cpex` instead of before. Either is a misconfiguration that would silently bypass CMF/APL policy. Set to `false` only when intentionally fronting non-MCP traffic through `cpex` for identity-only enforcement (legacy behavior). Note: MCP methods that legitimately carry no entity (e.g. `tools/list`, `initialize`, `prompts/list`) still pass — `require_mcp_metadata` only rejects when the metadata is missing entirely. | +| `init_timeout_secs` | u64 | no | Maximum time, in seconds, to wait for `PluginManager::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. | + +## Example + +```yaml +filter: cpex +config_path: /etc/praxis/cpex.yaml +body_access: read_write # optional; default read_only +require_mcp_metadata: true # optional; default true +init_timeout_secs: 30 # optional; default 30 +``` diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 72def051..b5765e18 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -43,6 +43,7 @@ Built-in filters organized by protocol and category. | Filter | Feature | Description | |--------|---------|-------------| | [`cors`](http/security/cors.md) | - | Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection. | +| [`cpex`](http/security/cpex.md) | `cpex` | Configuration block for a `cpex` filter slot in a Praxis filter chain. | | [`credential_injection`](http/security/credential_injection.md) | - | Injects per-cluster API credentials into upstream requests. | | [`csrf`](http/security/csrf.md) | - | CSRF protection filter that validates request origins against a trusted allowlist. | | [`forwarded_headers`](http/security/forwarded_headers.md) | - | Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers into upstream requests. | From 229279e27480bf94d456ad0968723665d927e27c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 22 Jun 2026 21:27:59 -0400 Subject: [PATCH 13/14] fix(cpex): address PR review findings Resolve findings from the PR #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 --- Cargo.lock | 1 + deny.toml | 4 +- docs/filters/http/security/cpex.md | 2 + .../src/builtins/http/security/cpex/README.md | 2 + .../src/builtins/http/security/cpex/config.rs | 15 +++ .../src/builtins/http/security/cpex/error.rs | 17 +++- .../src/builtins/http/security/cpex/filter.rs | 32 +++--- .../builtins/http/security/cpex/json_rpc.rs | 23 +++-- filter/src/builtins/http/security/cpex/mod.rs | 3 +- .../src/builtins/http/security/cpex/tests.rs | 34 +++++-- filter/src/registry.rs | 2 + tests/integration/Cargo.toml | 3 + .../integration/tests/suite/examples/cpex.rs | 98 ++++++++++++++++--- 13 files changed, 187 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c09a8402..c2244212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3277,6 +3277,7 @@ dependencies = [ "bytes", "futures", "http", + "jsonwebtoken", "praxis-proxy-core", "praxis-proxy-filter", "praxis-proxy-protocol", diff --git a/deny.toml b/deny.toml index c76a8c1a..de410eb9 100644 --- a/deny.toml +++ b/deny.toml @@ -35,4 +35,6 @@ highlight = "all" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -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"] diff --git a/docs/filters/http/security/cpex.md b/docs/filters/http/security/cpex.md index 439e5404..a2183c17 100644 --- a/docs/filters/http/security/cpex.md +++ b/docs/filters/http/security/cpex.md @@ -21,6 +21,7 @@ The referenced YAML is the CPEX policy document — plugins, routes, and identit | `body_access` | `read_only` \| `read_write` | no | Body-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. | | `require_mcp_metadata` | bool | no | Fail-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 praxis's built-in `mcp` filter, so its absence means either (a) the `mcp` filter is missing from the chain, or (b) it is ordered AFTER `cpex` instead of before. Either is a misconfiguration that would silently bypass CMF/APL policy. Set to `false` only when intentionally fronting non-MCP traffic through `cpex` for identity-only enforcement (legacy behavior). Note: MCP methods that legitimately carry no entity (e.g. `tools/list`, `initialize`, `prompts/list`) still pass — `require_mcp_metadata` only rejects when the metadata is missing entirely. | | `init_timeout_secs` | u64 | no | Maximum time, in seconds, to wait for `PluginManager::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_bytes` | usize | no | Maximum request/response body bytes buffered in `ReadWrite` mode. `ReadWrite` uses `StreamBuffer` to accumulate the whole body before APL field mutators run; without a cap an oversized payload could exhaust memory. Ignored in `ReadOnly` mode, which streams. The pipeline rejects an unbounded buffer at config load, so this always carries a concrete ceiling. | ## Example @@ -30,4 +31,5 @@ config_path: /etc/praxis/cpex.yaml body_access: read_write # optional; default read_only require_mcp_metadata: true # optional; default true init_timeout_secs: 30 # optional; default 30 +max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) ``` diff --git a/filter/src/builtins/http/security/cpex/README.md b/filter/src/builtins/http/security/cpex/README.md index 29983426..e60d42f4 100644 --- a/filter/src/builtins/http/security/cpex/README.md +++ b/filter/src/builtins/http/security/cpex/README.md @@ -52,6 +52,7 @@ filters: body_access: read_write # optional; default read_only require_mcp_metadata: true # optional; default true init_timeout_secs: 30 # optional; default 30 + max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) ``` | Field | Default | Purpose | @@ -60,6 +61,7 @@ filters: | `body_access` | `read_only` | `read_only` buffers the body for inspection and discards mutations. `read_write` re-serializes field mutations (`redact`, `assign`) back into the request and response. | | `require_mcp_metadata` | `true` | Reject any request that reaches the filter without `mcp.method` metadata. Set `false` only to front non-MCP traffic for identity-only enforcement. | | `init_timeout_secs` | `30` | Time budget for `PluginManager::initialize` at startup (identity plugins fetch JWKS over HTTPS). On expiry the build fails fast. | +| `max_buffer_bytes` | `10485760` | Max body bytes buffered in `read_write` mode (10 MiB). Bounds per-request memory against oversized payloads. Ignored in `read_only` mode. | ## The policy document diff --git a/filter/src/builtins/http/security/cpex/config.rs b/filter/src/builtins/http/security/cpex/config.rs index 0c189a29..cb148c8b 100644 --- a/filter/src/builtins/http/security/cpex/config.rs +++ b/filter/src/builtins/http/security/cpex/config.rs @@ -74,6 +74,15 @@ pub struct CpexFilterConfig { /// during the deploy. #[serde(default = "default_init_timeout_secs")] pub init_timeout_secs: u64, + + /// Maximum request/response body bytes buffered in `ReadWrite` + /// mode. `ReadWrite` uses `StreamBuffer` to accumulate the whole + /// body before APL field mutators run; without a cap an oversized + /// payload could exhaust memory. Ignored in `ReadOnly` mode, which + /// streams. The pipeline rejects an unbounded buffer at config + /// load, so this always carries a concrete ceiling. + #[serde(default = "default_max_buffer_bytes")] + pub max_buffer_bytes: usize, } /// `#[serde(default = ...)]` requires a free function for primitives @@ -88,6 +97,12 @@ fn default_init_timeout_secs() -> u64 { 30 } +/// Default `ReadWrite` body buffer ceiling. 10 MiB comfortably covers +/// JSON-RPC tool-call payloads while bounding per-request memory. +fn default_max_buffer_bytes() -> usize { + 10_485_760 // 10 MiB +} + /// What APL field-pipeline mutators on `args.` and /// `result.` are allowed to do to the upstream body and /// downstream response. diff --git a/filter/src/builtins/http/security/cpex/error.rs b/filter/src/builtins/http/security/cpex/error.rs index 9f8893a0..da788a68 100644 --- a/filter/src/builtins/http/security/cpex/error.rs +++ b/filter/src/builtins/http/security/cpex/error.rs @@ -28,7 +28,7 @@ const MCP_GATEWAY_DENIED_CODE: i64 = -32001; /// /// * HTTP 401 ([`auth_rejection`]) — identity / transport-level deny. /// * HTTP 200 ([`mcp_error_rejection`]) — application-level deny wrapped in a JSON-RPC error envelope. -/// * HTTP 500 ([`super::filter::missing_mcp_metadata_rejection`]) — `mcp.method` missing from filter metadata. +/// * HTTP 500 (`missing_mcp_metadata_rejection`) — `mcp.method` missing from filter metadata. /// /// Operators consuming this in audit / SIEM pipelines should treat the /// header value as a stable identifier (the code namespace is part of @@ -121,5 +121,18 @@ pub(super) fn mcp_error_envelope_bytes(violation: Option<&PluginViolation>, requ "data": { "violation": violation_code }, } }); - Bytes::from(serde_json::to_vec(&body).unwrap_or_default()) + // The envelope above is built entirely from owned `String`s and a + // pre-parsed `request_id` Value, so `to_vec` is infallible in + // practice. Fall back to a static, valid deny envelope rather than + // an empty body if that ever changes: every caller is a deny path + // that replaces the response body, so an empty body would weaken + // (never strengthen) enforcement, and panicking mid-response-phase + // (`block_in_place`) is worse still. + Bytes::from(serde_json::to_vec(&body).unwrap_or_else(|_| FALLBACK_DENY_ENVELOPE.to_vec())) } + +/// Static, always-valid JSON-RPC deny envelope used only if serializing +/// the dynamic envelope in [`mcp_error_envelope_bytes`] ever fails. +/// Keeps the deny path total without emitting an empty (fail-open) body. +const FALLBACK_DENY_ENVELOPE: &[u8] = + br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"denied by gateway","data":{"violation":"gateway.unknown"}}}"#; diff --git a/filter/src/builtins/http/security/cpex/filter.rs b/filter/src/builtins/http/security/cpex/filter.rs index 478d7772..c87a6909 100644 --- a/filter/src/builtins/http/security/cpex/filter.rs +++ b/filter/src/builtins/http/security/cpex/filter.rs @@ -95,6 +95,7 @@ const RUNTIME_REJECTED: u8 = 2; /// body_access: read_write # optional; default read_only /// require_mcp_metadata: true # optional; default true /// init_timeout_secs: 30 # optional; default 30 +/// max_buffer_bytes: 10485760 # optional; default 10 MiB (read_write only) /// ``` pub struct CpexFilter { /// Filter-level configuration parsed from the YAML block. Held so @@ -196,7 +197,7 @@ impl CpexFilter { /// # Errors /// /// Returns [`FilterError`] if the config block fails to parse - /// as a [`CpexFilterConfig`] or filter construction fails. + /// as a `CpexFilterConfig` or filter construction fails. pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { let cfg: CpexFilterConfig = parse_filter_config("cpex", config)?; let filter = Self::new(cfg)?; @@ -247,11 +248,17 @@ impl CpexFilter { entity_type: &str, entity_name: &str, ) -> Result { + // Snapshot the request headers once and reuse them for both + // identity resolution and the `x-session-id` lookup below, + // rather than cloning the whole header map twice per phase. + let headers = Self::snapshot_headers(ctx); + let session_id = headers.get("x-session-id").filter(|value| !value.is_empty()).cloned(); + let (id_result, _bg) = self .mgr .invoke_named::( HOOK_IDENTITY_RESOLVE, - Self::identity_payload(ctx), + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers), Extensions::default(), None, ) @@ -276,12 +283,9 @@ impl CpexFilter { // taint labels (`taint(label, session)`) persist across requests // in the same conversation and stay isolated between principals. // Absent header → cpex falls back to its identity-derived session. - if let Some(session_id) = Self::snapshot_headers(ctx) - .get("x-session-id") - .filter(|value| !value.is_empty()) - { + if let Some(session_id) = session_id { let mut agent = ext.agent.as_ref().map(|arc| (**arc).clone()).unwrap_or_default(); - agent.session_id = Some(session_id.clone()); + agent.session_id = Some(session_id); ext.agent = Some(Arc::new(agent)); } @@ -320,7 +324,9 @@ impl HttpFilter for CpexFilter { // back into `body`. `ReadOnly` inherits the default `Stream`. match self.cfg.body_access { BodyAccessMode::ReadOnly => BodyMode::Stream, - BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { max_bytes: None }, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { + max_bytes: Some(self.cfg.max_buffer_bytes), + }, } } @@ -334,7 +340,9 @@ impl HttpFilter for CpexFilter { fn response_body_mode(&self) -> BodyMode { match self.cfg.body_access { BodyAccessMode::ReadOnly => BodyMode::Stream, - BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { max_bytes: None }, + BodyAccessMode::ReadWrite => BodyMode::StreamBuffer { + max_bytes: Some(self.cfg.max_buffer_bytes), + }, } } @@ -345,14 +353,14 @@ impl HttpFilter for CpexFilter { // tokio runtime (praxis `work_stealing: false`). Rather than // crash mid-response, refuse to operate up front. After the // first request this collapses to a single atomic load. - match self.runtime_check.load(Ordering::Relaxed) { + match self.runtime_check.load(Ordering::Acquire) { RUNTIME_UNCHECKED => { let flavor = tokio::runtime::Handle::current().runtime_flavor(); if matches!(flavor, tokio::runtime::RuntimeFlavor::CurrentThread) { - self.runtime_check.store(RUNTIME_REJECTED, Ordering::Relaxed); + self.runtime_check.store(RUNTIME_REJECTED, Ordering::Release); return Err(current_thread_runtime_error()); } - self.runtime_check.store(RUNTIME_OK, Ordering::Relaxed); + self.runtime_check.store(RUNTIME_OK, Ordering::Release); }, RUNTIME_REJECTED => return Err(current_thread_runtime_error()), _ => {}, // RUNTIME_OK — fall through. diff --git a/filter/src/builtins/http/security/cpex/json_rpc.rs b/filter/src/builtins/http/security/cpex/json_rpc.rs index f9e91d0d..c3458683 100644 --- a/filter/src/builtins/http/security/cpex/json_rpc.rs +++ b/filter/src/builtins/http/security/cpex/json_rpc.rs @@ -2,16 +2,7 @@ // Copyright (c) 2026 Praxis Contributors //! JSON-RPC body parsing + typed CMF content-part builders. -// The builders/re-serializers branch on MCP method and conditionally -// touch nested envelope fields; `too_many_lines` and -// `cognitive_complexity` fire on the longer ones but the alternatives -// (per-method helpers) hurt readability of a tightly-coupled -// envelope shape. -#![allow( - clippy::too_many_lines, - clippy::cognitive_complexity, - reason = "envelope orchestration; splitting per-method obscures the JSON-RPC shape" -)] +//! //! Praxis's `mcp` filter parses JSON-RPC bodies and stashes //! `mcp.method` / `mcp.name` in `filter_metadata`, but it doesn't //! materialize `params.arguments` (or `result.content`) into a typed @@ -68,6 +59,10 @@ pub(super) fn json_rpc_id_value(body: &Bytes) -> serde_json::Value { /// malformed or absent body, falls back to an empty content list — the /// caller can still dispatch CMF (entity coords drive routing), just /// without typed args available to predicates. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] pub(super) fn build_content_for_method( method: &str, entity_name: &str, @@ -155,6 +150,10 @@ pub(super) fn build_content_for_method( /// blast radius of the rewrite — operators relying on a byte-stable /// envelope (signature validation, content-hash matching) only see /// changes when APL actually mutated. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] pub(super) fn reserialize_json_rpc_body(original: &Bytes, method: &str, message: &Message) -> Option { let mut envelope: serde_json::Value = serde_json::from_slice(original).ok()?; let params = envelope.get_mut("params")?; @@ -217,6 +216,10 @@ pub(super) fn reserialize_json_rpc_body(original: &Bytes, method: &str, message: /// and [`reserialize_json_rpc_response_body`] never rewrote, leaking it /// downstream. The view here and the bytes emitted there are kept to /// the same content set. +#[expect( + clippy::too_many_lines, + reason = "per-method envelope orchestration; splitting per-method obscures the JSON-RPC shape" +)] pub(super) fn build_response_content_for_method( method: &str, entity_name: &str, diff --git a/filter/src/builtins/http/security/cpex/mod.rs b/filter/src/builtins/http/security/cpex/mod.rs index a4e0094a..b6cb9b2b 100644 --- a/filter/src/builtins/http/security/cpex/mod.rs +++ b/filter/src/builtins/http/security/cpex/mod.rs @@ -15,12 +15,11 @@ mod json_rpc; pub use filter::CpexFilter; #[cfg(test)] -#[allow( +#[expect( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic, - clippy::needless_raw_strings, reason = "tests" )] mod tests; diff --git a/filter/src/builtins/http/security/cpex/tests.rs b/filter/src/builtins/http/security/cpex/tests.rs index 80ca3391..e4238e2f 100644 --- a/filter/src/builtins/http/security/cpex/tests.rs +++ b/filter/src/builtins/http/security/cpex/tests.rs @@ -17,7 +17,7 @@ use tempfile::TempDir; use super::{config::CpexFilterConfig, filter::CpexFilter}; use crate::{ FilterAction, - filter::HttpFilter, + filter::HttpFilter as _, test_utils::{make_filter_context, make_request}, }; @@ -106,7 +106,7 @@ fn write_single_plugin_config() -> (TempDir, String) { /// `sub`), a `kind: cel` PDP declared globally, and a route whose `cel:` /// expression allows only `alice`. Exercises the `apl-pdp-cel` backend /// end-to-end through the filter's CMF dispatch. -#[allow( +#[expect( clippy::too_many_lines, reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" )] @@ -179,7 +179,7 @@ async fn dispatch_echo_as(filter: &CpexFilter, subject: &str) -> FilterAction { /// session carries that taint. Identity is the HS256 jwt plugin so /// `subject.id` resolves; the taint persists in the in-process session /// store keyed by the resolved session id. -#[allow( +#[expect( clippy::too_many_lines, reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" )] @@ -255,7 +255,7 @@ async fn dispatch_tool_session(filter: &CpexFilter, subject: &str, tool: &str, s /// header. Demonstrates the multi-source agentic identity story PR1 /// targets — one request can carry user + agent JWTs simultaneously, /// both validated, both contributing to a typed `Extensions` context. -#[allow( +#[expect( clippy::too_many_lines, reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" )] @@ -314,6 +314,10 @@ fn write_multi_source_config() -> (TempDir, String) { /// lazily (the pool dials on first request), so this config loads /// without a running Valkey — it pins that the factory is registered /// and the flat `session_store` block parses and resolves. +#[expect( + clippy::too_many_lines, + reason = "test fixture — the YAML literal is the bulk; splitting helpers would obscure the shape under test" +)] fn write_valkey_session_store_config() -> (TempDir, String) { let dir = TempDir::new().expect("create tempdir"); let cfg_path = dir.path().join("cpex.yaml"); @@ -365,6 +369,7 @@ fn build_filter(config_path: String) -> CpexFilter { body_access: super::config::BodyAccessMode::ReadOnly, require_mcp_metadata: true, init_timeout_secs: 30, + max_buffer_bytes: 10_485_760, }; CpexFilter::new(cfg).expect("filter should construct") } @@ -374,13 +379,23 @@ fn build_filter(config_path: String) -> CpexFilter { // ===================================================================== /// The minimal valid config carries only `config_path:`; all other -/// fields (`body_access`, `require_mcp_metadata`, `init_timeout_secs`) -/// take their documented defaults. +/// fields (`body_access`, `require_mcp_metadata`, `init_timeout_secs`, +/// `max_buffer_bytes`) take their documented defaults. #[test] fn config_parses_minimal_yaml() { let yaml = "config_path: /etc/praxis/cpex.yaml"; let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); assert_eq!(cfg.config_path, "/etc/praxis/cpex.yaml", "config_path round-trips",); + assert_eq!(cfg.max_buffer_bytes, 10_485_760, "max_buffer_bytes defaults to 10 MiB",); +} + +/// `max_buffer_bytes` is operator-tunable; an explicit value overrides +/// the 10 MiB default so deployments can bound `ReadWrite` buffering. +#[test] +fn config_max_buffer_bytes_override() { + let yaml = "config_path: /etc/praxis/cpex.yaml\nmax_buffer_bytes: 1048576"; + let cfg: CpexFilterConfig = serde_yaml::from_str(yaml).expect("parse"); + assert_eq!(cfg.max_buffer_bytes, 1_048_576, "explicit max_buffer_bytes wins"); } /// `config_path:` is mandatory — there's no default that would let @@ -682,6 +697,7 @@ async fn missing_mcp_metadata_passes_when_not_required() { body_access: super::config::BodyAccessMode::ReadOnly, require_mcp_metadata: false, init_timeout_secs: 30, + max_buffer_bytes: 10_485_760, }; let filter = CpexFilter::new(cfg).expect("filter should construct"); @@ -851,7 +867,7 @@ fn fit_to_original_length_truncates_on_grow() { let new = bytes::Bytes::from_static(b"a much longer rewritten payload"); let out = fit_to_original_length(new.clone(), 4, "tools/call", "test"); assert_eq!(out.len(), 4, "grow path must truncate to the original length"); - assert_eq!(&out[..], &new[..4], "truncation keeps the leading bytes"); + assert_eq!(&*out, &new[..4], "truncation keeps the leading bytes"); } // ===================================================================== @@ -1349,7 +1365,7 @@ fn on_response_body_continues_on_partial_chunks() { /// audience attaches, the other is logged and skipped, and the /// returned count reflects what actually went on the wire. #[test] -#[allow(clippy::too_many_lines, reason = "test fixture construction")] +#[expect(clippy::too_many_lines, reason = "test fixture construction")] fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { use std::sync::Arc; @@ -1402,7 +1418,7 @@ fn attach_delegated_tokens_first_writer_wins_per_outbound_header() { /// multi-audience flows (the common case for routes that delegate /// to multiple upstream APIs simultaneously). #[test] -#[allow(clippy::too_many_lines, reason = "test fixture construction")] +#[expect(clippy::too_many_lines, reason = "test fixture construction")] fn attach_delegated_tokens_distinct_outbound_headers_all_attach() { use std::sync::Arc; diff --git a/filter/src/registry.rs b/filter/src/registry.rs index 91900ecc..9255f2ab 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -366,6 +366,8 @@ mod tests { names.contains(&"openai_response_store"), "response_store should be registered" ); + #[cfg(feature = "cpex")] + assert!(names.contains(&"cpex"), "cpex should be registered"); } #[test] diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 592415a8..26c5f30a 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -22,6 +22,9 @@ async-trait = { workspace = true } futures = { workspace = true } bytes = { workspace = true } http = { workspace = true } +# Used only by the cpex example test to mint HS256 JWTs for the +# happy-path case (matches the filter crate's signing dependency). +jsonwebtoken = "9" praxis-core = { workspace = true } praxis-filter = { workspace = true } praxis-protocol = { workspace = true } diff --git a/tests/integration/tests/suite/examples/cpex.rs b/tests/integration/tests/suite/examples/cpex.rs index 61559330..ac17ed97 100644 --- a/tests/integration/tests/suite/examples/cpex.rs +++ b/tests/integration/tests/suite/examples/cpex.rs @@ -5,26 +5,59 @@ //! //! Exercises the `examples/configs/security/cpex.yaml` filter chain //! end-to-end: praxis is configured with the `mcp` → `cpex` → `router` -//! → `load_balancer` chain, an HTTP request is sent without an -//! `Authorization` header, and we assert the filter rejects with -//! HTTP 401 (the cpex identity gate's `auth_rejection` path). +//! → `load_balancer` chain. Two cases: //! -//! Why no happy-path test here: a positive case requires minting an -//! HS256 JWT and constructing a valid MCP JSON-RPC body that praxis's -//! built-in `mcp` filter accepts. The unit tests in -//! `filter/src/builtins/http/security/cpex/tests.rs` cover that path -//! against the filter trait directly. The intent here is the -//! CLAUDE.md "Adding a Filter" integration-test requirement: prove -//! the example config loads, the filter constructs from the policy -//! YAML, and the chain produces the documented error response. +//! * **Deny** — a request with no `Authorization` header is rejected with HTTP 401 (the cpex identity gate's +//! `auth_rejection` path). +//! * **Allow** — a request carrying a valid HS256 JWT (signed with the fixture's shared secret) resolves identity, +//! finds no APL route to gate it, and passes through to the backend with HTTP 200. +//! +//! Together these prove the example config loads, the filter +//! constructs from the policy YAML, and the chain both blocks +//! unauthenticated traffic and forwards authenticated traffic — the +//! CLAUDE.md "Adding a Filter" end-to-end functional requirement. -use std::collections::HashMap; +use std::{ + collections::HashMap, + time::{SystemTime, UNIX_EPOCH}, +}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use praxis_core::config::Config; use praxis_test_utils::{ example_config_path, free_port, http_send, parse_status, patch_yaml, start_backend_with_shutdown, start_proxy, }; +// Identity parameters mirrored from `tests/integration/fixtures/cpex-policy.yaml`. +// The happy-path JWT must match the fixture's trusted issuer, audience, +// algorithm, and shared secret for the `jwt-user` identity plugin to +// accept it. +const FIXTURE_ISSUER: &str = "https://idp.example.com"; +const FIXTURE_AUDIENCE: &str = "praxis-cpex-example"; +const FIXTURE_SECRET: &str = "REPLACE-WITH-A-PROPERLY-RANDOM-SHARED-SECRET-DO-NOT-COMMIT"; + +/// Mint an HS256 JWT accepted by the fixture's `jwt-user` plugin: the +/// fixture's issuer/audience, the given subject, and a fresh `exp`. +fn mint_fixture_jwt(subject: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_secs(); + let claims = serde_json::json!({ + "iss": FIXTURE_ISSUER, + "aud": FIXTURE_AUDIENCE, + "sub": subject, + "iat": now, + "exp": now + 300, + }); + encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(FIXTURE_SECRET.as_bytes()), + ) + .expect("sign fixture JWT") +} + // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- @@ -32,7 +65,7 @@ use praxis_test_utils::{ /// Load the CPEX praxis example, patch the relative `config_path` /// reference into an absolute path, then patch ports. Returns a /// fully-parsed [`Config`] ready for [`start_proxy`]. -#[allow(clippy::needless_pass_by_value, reason = "callers construct the map inline")] +#[expect(clippy::needless_pass_by_value, reason = "callers construct the map inline")] fn load_cpex_example(proxy_port: u16, port_map: HashMap<&str, u16>) -> Config { let praxis_yaml_path = example_config_path("security/cpex.yaml"); let policy_yaml_path = format!("{}/fixtures/cpex-policy.yaml", env!("CARGO_MANIFEST_DIR")); @@ -90,3 +123,42 @@ fn cpex_example_missing_authorization_rejects_401() { "rejection should surface the violation code via X-Cpex-Violation; raw response:\n{raw}", ); } + +#[test] +fn cpex_example_valid_jwt_passes_through() { + let backend_guard = start_backend_with_shutdown("ok"); + let proxy_port = free_port(); + let config = load_cpex_example(proxy_port, HashMap::from([("127.0.0.1:3000", backend_guard.port())])); + let proxy = start_proxy(&config); + + // Same well-formed MCP body as the deny case, but now carrying a + // valid HS256 JWT. The cpex identity gate resolves it, the fixture + // declares no APL routes so policy dispatch is a no-op, and the + // request reaches the backend (HTTP 200, body "ok"). + let token = mint_fixture_jwt("alice"); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}"#; + let raw = http_send( + proxy.addr(), + &format!( + "POST /mcp HTTP/1.1\r\n\ + Host: localhost\r\n\ + Authorization: Bearer {token}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len(), + ), + ); + + assert_eq!( + parse_status(&raw), + 200, + "a valid JWT should resolve identity and pass through to the backend; raw response:\n{raw}", + ); + assert!( + raw.contains("ok"), + "the upstream backend body should reach the client on the allow path; raw response:\n{raw}", + ); +} From dbcd8db4529912aab5a90a8acbc0b19ddce29bd2 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 23 Jun 2026 10:32:36 -0400 Subject: [PATCH 14/14] build: bump quinn-proto to 0.11.15 for RUSTSEC-2026-0185 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 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af659a50..53058932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3592,9 +3592,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4",