diff --git a/.github/workflows/docs-build.yaml b/.github/workflows/docs-build.yaml index 60ffc2f7..dddb3bb3 100644 --- a/.github/workflows/docs-build.yaml +++ b/.github/workflows/docs-build.yaml @@ -65,11 +65,11 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - for target in demos/main policy/v0.5.0; do + for target in demos/main policy/v0.5.0 policy/v0.6.0; do repo="${target%/*}" ref="${target#*/}" for attempt in 1 2 3; do - if gh api "repos/praxis-proxy/$repo/git/trees/$ref?recursive=1" > "$RUNNER_TEMP/$repo-tree.json"; then + if gh api "repos/praxis-proxy/$repo/git/trees/$ref?recursive=1" > "$RUNNER_TEMP/$repo-$ref-tree.json"; then break fi if [ "$attempt" -eq 3 ]; then exit 1; fi @@ -83,8 +83,8 @@ jobs: from pathlib import Path missing = [] - for repo, ref in (("demos", "main"), ("policy", "v0.5.0")): - tree = json.loads((Path(sys.argv[1]) / f"{repo}-tree.json").read_text()) + for repo, ref in (("demos", "main"), ("policy", "v0.5.0"), ("policy", "v0.6.0")): + tree = json.loads((Path(sys.argv[1]) / f"{repo}-{ref}-tree.json").read_text()) if tree.get("truncated"): raise SystemExit(f"{repo} repository tree was truncated") available = {entry["path"] for entry in tree["tree"] if entry["type"] == "blob"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec16f73..8537fbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,26 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.4.0] - 2026-11-09 + +### Changed + +- Upgrade the engine to PPE 0.6.0 with an explicit RustCrypto backend. +- Preserve inbound identity headers in Python and Go bindings and thread request context through fused identity calls. +- **Breaking:** CIBA plugins require `store: { kind: memory }` or an explicit Valkey store. JWT credential extraction uses location-independent denial codes. +- **Breaking:** conversation history contains CMF messages; Go uses `[]Message`. FFI ABI is now 4; rebuild the Go module and native library together. +- Go HTTP extensions carry query strings and normalized client addresses; identities carry verified DPoP key bindings. Serialized queries are redacted and must be restored from the original request between hooks. +- The legacy Python worker rejects unsupported query, LLM-request and acquired-metadata access rather than dropping it. Rate-limit hook dispatch and nonserialized LLM-request/acquired-metadata inputs require a Rust host. +- PPE now accepts enabled audit sinks alongside policy routes. Reference sinks require explicit output destinations and remain outside the bundled set. + +### Fixed + +- Make the parity gate propagate Cargo failures on macOS Make 3.81. + +### Security + +- Update `ctutils`, `rustls-webpki` and `webpki-roots` to patched versions. The separate Biscuit dependency still resolves vulnerable `crypto-bigint` 0.5.5 (RUSTSEC-2026-0387); release audit remains blocked pending an upstream-compatible fix. + ## [0.3.1] - 2026-10-15 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ff60b93..92ac5cb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,17 +85,17 @@ and a `version`; when adding a new internal dependency, include the `version` so the crate remains publishable. `cpex-ffi` is `publish = false` (distributed as signed prebuilt artifacts). -For 0.3.1 the complete crates.io publish order is `cpex-hosts-python`, +For 0.4.0 the complete crates.io publish order is `cpex-hosts-python`, `cpex-plugin-delegator-biscuit`, `cpex`, then `cpex-python`. The facade consumes -PPE 0.5.0 from crates.io; the removed CPEX core, SDK, APL and builtin crates +PPE 0.6.0 from crates.io; the removed CPEX core, SDK, APL and builtin crates remain at 0.2.3 and must not be added back to the release list. Run the local preparation checks before tagging: ```bash -make release-check VERSION=0.3.1 # manifests, Go demo, PPE pins, publish set, ABI -make release-dry VERSION=0.3.1 # cargo-release preview; no changes -make release-verify VERSION=0.3.1 # lint, tests, verified .crates, FFI, examples +make release-check VERSION=0.4.0 # manifests, Go demo, PPE pins, publish set, ABI +make release-dry VERSION=0.4.0 # cargo-release preview; no changes +make release-verify VERSION=0.4.0 # lint, tests, verified .crates, FFI, examples ``` `make publish-dry` builds and verifies all four `.crate` archives in @@ -112,8 +112,8 @@ The Rust workflow packages and verifies every retained crate before any publish, and tests the facade with `python-host` enabled as well. The FFI workflow checks all five target bundles, with Go race tests on the GNU/Linux and macOS targets. Actual publishing requires a tag matching the workspace -version. `make tag VERSION=0.3.1` creates and pushes the `v0.3.1`, -`go/cpex/v0.3.1` and `0.3.1` tags; it starts publication immediately. +version. `make tag VERSION=0.4.0` creates and pushes the `v0.4.0`, +`go/cpex/v0.4.0` and `0.4.0` tags; it starts publication immediately. The broader integration checks remain separate: `make test-python-e2e` requires a compatible `CPEX_PYTHON_SOURCE` checkout, `make parity` needs diff --git a/Cargo.lock b/Cargo.lock index 6654f1ee..32071961 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,7 +603,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpex" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cpex-hosts-python", "praxis-policy", @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "cpex-demo-ffi" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "cpex-ffi", @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "cpex-ffi" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "cpex-hosts-python" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "chrono", @@ -661,7 +661,7 @@ dependencies = [ [[package]] name = "cpex-parity-harness" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -681,7 +681,7 @@ dependencies = [ [[package]] name = "cpex-plugin-delegator-biscuit" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "biscuit-auth", @@ -695,7 +695,7 @@ dependencies = [ [[package]] name = "cpex-python" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "cpex", @@ -710,7 +710,7 @@ dependencies = [ [[package]] name = "cpex-tutorial" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "cpex", @@ -796,9 +796,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] @@ -1985,15 +1985,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "msvc_spectre_libs" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e871a9861f3664f18b7e04e9301d4edd55090c2dadb4b1c602e26ab32b1f5b" -dependencies = [ - "cc", -] - [[package]] name = "murmur3" version = "0.4.1" @@ -2299,9 +2290,9 @@ dependencies = [ [[package]] name = "praxis-policy" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38539da1fc52a1cad1124aba99216eed98d602212f65ea913c37f7c96c6a526" +checksum = "662b03ab6155d6789464d7e9e0b4d2065900cd9d04b370d532f1b18bb1e7006b" dependencies = [ "async-trait", "bytes", @@ -2322,9 +2313,9 @@ dependencies = [ [[package]] name = "praxis-policy-apl-cmf" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c17a247088d1aead6446293fa37affc68cd695e1f24cd93983fc9e0bfb2fae" +checksum = "200333ebf208821f40f17070a27936d32867284605c103657fedaed6c12f504b" dependencies = [ "chrono", "praxis-policy-apl-core", @@ -2334,9 +2325,9 @@ dependencies = [ [[package]] name = "praxis-policy-apl-core" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa4564e982ddf61d7bb79a1e914a402be3d70f2e849370d1a7051c7f02274be" +checksum = "abade295a61836accbfb9c860b3196e5491a34a378ca4769566674779511d1fa" dependencies = [ "async-trait", "futures", @@ -2351,9 +2342,9 @@ dependencies = [ [[package]] name = "praxis-policy-apl-runtime" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94af76898d0400135296c76b0a19a5c3d4c783a9824d3638726b70c012ee195b" +checksum = "1ce569d880efacc2c4893bdda5c1751095a90b2e9707ee92ff12366a61c91f5d" dependencies = [ "async-trait", "chrono", @@ -2361,7 +2352,6 @@ dependencies = [ "praxis-policy-apl-core", "praxis-policy-core", "serde_json", - "sha2 0.11.0", "thiserror 2.0.20", "tokio", "tracing", @@ -2370,9 +2360,9 @@ dependencies = [ [[package]] name = "praxis-policy-builtins" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514f8817828c4563b523c48e25e81afcfb6b4cd1e9282136f9dff8c13d506601" +checksum = "9629f9ab2cd663fb095a3e75feffe90497250af1dda841f70fac3b4ed80f1ed7" dependencies = [ "arc-swap", "async-trait", @@ -2383,10 +2373,10 @@ dependencies = [ "chrono", "deadpool-redis", "futures", - "getrandom 0.4.3", "hmac 0.13.0", "jsonwebtoken", "moka", + "praxis-policy-apl-cmf", "praxis-policy-apl-core", "praxis-policy-apl-runtime", "praxis-policy-core", @@ -2407,15 +2397,16 @@ dependencies = [ [[package]] name = "praxis-policy-core" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd60a2e3d73a8d139684dcab02afe73bba5e6496f15fbba1986f935b8b4174a4" +checksum = "55950103a82d4f7d02945998bd9c42ff722b395e97e60dc4f721297467a0a711" dependencies = [ "arc-swap", "async-trait", "bytes", "chrono", "futures", + "getrandom 0.4.3", "hashbrown 0.17.1", "hmac 0.13.0", "http", @@ -2435,9 +2426,9 @@ dependencies = [ [[package]] name = "praxis-policy-orchestration" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f194b463d5304f8267f89e3f4ec1c50953ff3ad8bb287186455943bb78b566b" +checksum = "3b5057ca0a8c242d91f5dd5dc4fdfb779dd7deaebcafb6bff49520cad5ec08c2" dependencies = [ "futures", "tokio", @@ -2796,6 +2787,7 @@ dependencies = [ "rustls", "rustls-native-certs", "ryu", + "sha1_smol", "socket2", "tokio", "tokio-rustls", @@ -2864,9 +2856,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regorus" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d90cbdbc4d2900378cf7dbb0c8df85a28e6de07fad75c60cd4d73283eded23" +checksum = "800007e261eac92aecf7d709fd4110de132bf79651d4bd1e0137db9debd0cfe7" dependencies = [ "anyhow", "chrono", @@ -2876,12 +2868,9 @@ dependencies = [ "indexmap 2.14.0", "lazy_static", "lru", - "msvc_spectre_libs", "num-bigint 0.5.1", "num-traits", - "parking_lot", "postcard", - "rand 0.10.2", "regex", "semver", "serde", @@ -3034,9 +3023,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -3256,6 +3245,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.9.9" @@ -3952,15 +3947,15 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "verus_builtin" -version = "0.0.0-2026-08-09-0044" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f2985ebd252c492375e32f2bfcff6e6a04523009a0d624785d3468f3729158" +checksum = "856c9979b5ed3b75a2371d8667602da1839ab0cbb3720a2c1c8a182bcdb27cf7" [[package]] name = "verus_builtin_macros" -version = "0.0.0-2026-08-23-0033" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f5b1ed2151955cfe25ebfc1382ab422ae004e5cda6c502871425cc9e2ad5f3" +checksum = "466bb5ed102507ccbcd002410e73716c9eb27b602980d81bbd176860ace8a89b" dependencies = [ "convert_case", "proc-macro2", @@ -3973,9 +3968,9 @@ dependencies = [ [[package]] name = "verus_prettyplease" -version = "0.0.0-2026-08-09-0044" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fc115de5fb3806bc362060bb683024660ed2bc13c1183d1f01d464e4537139" +checksum = "71956c0dcf55f57fd5a5335fff16981f7e0912d2e887914518c5a64fe36c063c" dependencies = [ "proc-macro2", "verus_syn", @@ -3983,9 +3978,9 @@ dependencies = [ [[package]] name = "verus_state_machines_macros" -version = "0.0.0-2026-08-02-0125" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f77ff8d121edb1bf2651335769a80a2f611da8573c3b498434a66b3162805f" +checksum = "5f78ea16629331605a440e9e0a259dd3492a78212186ba82253ad0988127407f" dependencies = [ "indexmap 2.14.0", "proc-macro2", @@ -3995,9 +3990,9 @@ dependencies = [ [[package]] name = "verus_syn" -version = "0.0.0-2026-08-02-0125" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17237ea6d267e457d53ce36f55b315fc9edd26eb88c765dd95e035c0b415869" +checksum = "7d8b53574934e1f608dfccd809ce17f3733d6eb99c14ce8220326121929e1637" dependencies = [ "proc-macro2", "quote", @@ -4006,9 +4001,9 @@ dependencies = [ [[package]] name = "vstd" -version = "0.0.0-2026-08-23-0033" +version = "0.0.0-2026-09-06-0133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b32ef60c0a4ce7d85691135c15fada7ab84b2c1471322d320fba313190e2e9" +checksum = "9e5391d6f656ab9a485169f7e9fe87d3db1f0a6aa116fcb630dd0bef224170e7" dependencies = [ "verus_builtin", "verus_builtin_macros", @@ -4126,9 +4121,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index c71b0691..226cf401 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ default-members = [ ] [workspace.package] -version = "0.3.1" +version = "0.4.0" edition = "2024" # MSRV — keep in sync with rust-toolchain.toml `channel` and clippy.toml `msrv`. rust-version = "1.96" @@ -82,7 +82,7 @@ serde_bytes = "0.11" chrono = { version = "0.4", features = ["serde"] } # PPE facade. Upgrade the exact pin deliberately and rerun the parity suite. -ppe = { package = "praxis-policy", version = "=0.5.0", default-features = false } +ppe = { package = "praxis-policy", version = "=0.6.0", default-features = false, features = ["crypto-rustcrypto"] } # Internal workspace crates — single source of truth for their path + version # requirement. Members depend on these via `{ workspace = true }` so a version @@ -91,9 +91,9 @@ ppe = { package = "praxis-policy", version = "=0.5.0", default-features = false # time cargo substitutes it for the `path`. `cargo release` keeps these in sync # with the workspace version. Keys are package names (builtins differ from their # directory names), paths are workspace-root-relative. -cpex = { path = "crates/cpex", version = "0.3.1" } -cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.3.1" } -cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.3.1", default-features = false } +cpex = { path = "crates/cpex", version = "0.4.0" } +cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", version = "0.4.0" } +cpex-hosts-python = { path = "crates/cpex-hosts-python", version = "0.4.0", default-features = false } # Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked # statically into host binaries, so its compiled size flows straight into diff --git a/Makefile b/Makefile index 9bc45d5b..c6155243 100644 --- a/Makefile +++ b/Makefile @@ -214,7 +214,8 @@ PARITY_LOG = target/parity-test.log parity: @mkdir -p target @echo "🔁 parity against the 0.2.3 snapshots ..." - @PARITY_VALKEY_URL="$(PARITY_VALKEY_URL)" \ + @set -o pipefail; \ + PARITY_VALKEY_URL="$(PARITY_VALKEY_URL)" \ $(CARGO) test -p cpex-parity-harness -- --nocapture 2>&1 | tee $(PARITY_LOG) @if [ -n "$(PARITY_VALKEY_URL)" ] && grep -q 'SKIPPED' $(PARITY_LOG); then \ echo "❌ PARITY_VALKEY_URL is set but some parity checks were skipped:"; \ diff --git a/README.md b/README.md index 609f06df..a29d67dc 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ pip install maturin && (cd bindings/python && maturin develop) # Python, from go get github.com/contextforge-org/cpex/go/cpex # Go (links the signed libcpex_ffi artifact) ``` -`cpex = "0.3"` compiles in every bundled builtin: JWT and API-key identity, OAuth delegation, CIBA approval, the Cedar, CEL and OPA decision points, the Valkey session store, the Vault secret provider, and an outbound HTTP transport. The one optional feature is `python-host`, which runs existing Python CPEX plugins out of process. +`cpex = "0.4"` compiles in every bundled builtin: JWT and API-key identity, OAuth delegation, CIBA approval, the Cedar, CEL and OPA decision points, the Valkey session store, the Vault secret provider, and an outbound HTTP transport. The one optional feature is `python-host`, which runs existing Python CPEX plugins out of process. ```rust use std::sync::Arc; @@ -173,7 +173,7 @@ The engine, the APL compiler and runtime, and the builtins come from the [praxis |------|-------------| | [`crates/cpex`](crates/cpex) | Host facade: re-exports `praxis-policy` (`PolicyEngine`, `install_builtins`, `prelude`, the builtin factories, `HyperTransport`) | | [`crates/cpex-hosts-python`](crates/cpex-hosts-python) | Runs existing Python CPEX plugins out of process, one virtualenv per plugin (`python-host` feature) | -| [`crates/cpex-ffi`](crates/cpex-ffi) | C ABI v3 (`cdylib`/`staticlib`) used by the Go package | +| [`crates/cpex-ffi`](crates/cpex-ffi) | C ABI v4 (`cdylib`/`staticlib`) used by the Go package | | [`builtins/plugins/delegator-biscuit`](builtins/plugins/delegator-biscuit) | Biscuit capability-token delegator | | [`bindings/python`](bindings/python) | PyO3 bindings: the Python `cpex` package | | [`go/cpex`](go/cpex) | Go bindings over the C ABI | diff --git a/bindings/python/MIGRATION.md b/bindings/python/MIGRATION.md index 5bd6dc46..c1ee40b1 100644 --- a/bindings/python/MIGRATION.md +++ b/bindings/python/MIGRATION.md @@ -11,15 +11,15 @@ from cpex.framework import PluginManager from cpex.framework.models import PluginResult # After (Rust-backed) -from cpex import PluginManager, PipelineResult +from cpex import PolicyEngine, PipelineResult ``` ## API Mapping Table | Concept | Legacy (`./cpex/`) | Rust-backed (`bindings/python/`) | |---------|-------------------|----------------------------------| -| Import | `from cpex.framework import PluginManager` | `from cpex import PluginManager` | -| Construction | `PluginManager()` (Borg singleton) | `PluginManager(config_path)` (explicit config) | +| Import | `from cpex.framework import PluginManager` | `from cpex import PolicyEngine` | +| Construction | `PluginManager()` (Borg singleton) | `PolicyEngine(config_path)` (explicit config) | | Initialize | `await manager.initialize()` | `await manager.initialize()` | | Shutdown | `await manager.shutdown()` | `await manager.shutdown()` | | Invoke | `result, violations = await manager.invoke_hook(hook, payload, context)` (2-tuple) | `result = await manager.invoke_hook(hook, payload)` (single result) | @@ -40,7 +40,7 @@ Hook names follow the engine (`cmf.tool_pre_invoke`, `identity.resolve`, naming its replacement, because it would reach no handler and come back allowed. The Rust-backed package picks the payload type from the hook name: `cmf.*` hooks use typed `MessagePayload`, `identity.resolve` uses -typed `IdentityPayload`, and all other hooks use `GenericPayload`. For the +typed `IdentityPayload`, and other supported hooks use `GenericPayload`. The two `ratelimit.*` hooks require a Rust HTTP payload and are rejected. For the two typed cases, a dict that doesn't match the schema raises `ValueError`. An `identity.resolve` payload may set only `source`, `source_header`, @@ -123,3 +123,9 @@ source .venv-cpex-rust/bin/activate pip install maturin cd bindings/python && maturin develop ``` + +## CPEX 0.4 / PPE 0.6 + +CPEX uses RustCrypto. CIBA needs an explicit `store: { kind: memory }` or a Valkey store. Conversation `history` entries must be CMF message dictionaries; put free-form text in `summary`. Thread `result.context_table` through hooks in the same request, and start a fresh table for a new request. + +Non-null `llm_request` and acquired `metadata` inputs are rejected because they require Rust host APIs. HTTP results redact `query` to `[REDACTED]`; restore the original query before the next hook. See the [0.4 migration guide](../../docs/content/docs/migration-0.4.md) for the complete contract. diff --git a/bindings/python/README.md b/bindings/python/README.md index bc463fbc..da1b9926 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -128,7 +128,7 @@ Invoke a hook by name. Returns a `PipelineResult`. default `dispatch: policy`, `meta.entity_type` and `meta.entity_name` pick the route; a request without them is denied with `unidentified_request` when the config declares a policy. -- `context_table`: optional `dict` for stateful plugins. +- `context_table`: pass the returned table to subsequent hooks in the same request; start a new request with no table. **Raises:** - `ValueError`: a hook name removed in 0.3; an `identity.resolve` @@ -273,3 +273,9 @@ python bindings/python/tests/smoke_wheel.py --wheel target/wheels/cpex-*.whl See [MIGRATION.md](MIGRATION.md) for a mapping from the legacy pure-Python `cpex` framework to this Rust-backed package. + +## PPE 0.6 inputs + +CPEX 0.4 uses RustCrypto. Conversation history contains CMF message dictionaries; summaries belong in `summary`. HTTP query is raw text without `?` and is redacted in results: restore it from the original request before a separate hook call. `client_address` comes from the trusted host peer context. + +Non-null `llm_request` and acquired `metadata` inputs, and the two `ratelimit.*` hooks, require Rust APIs and are rejected. CIBA configuration needs an explicit correlation store. See the [0.4 migration guide](../../docs/content/docs/migration-0.4.md). diff --git a/bindings/python/python/cpex/_lib.pyi b/bindings/python/python/cpex/_lib.pyi index 4241ed86..a906fc44 100644 --- a/bindings/python/python/cpex/_lib.pyi +++ b/bindings/python/python/cpex/_lib.pyi @@ -51,6 +51,7 @@ class PolicyEngine: ) -> "PolicyEngine": ... def initialize(self) -> Awaitable[None]: ... def shutdown(self) -> Awaitable[None]: ... + # Thread context within one request; restore live query from the original request. def invoke_hook( self, hook_name: str, diff --git a/bindings/python/src/conversions.rs b/bindings/python/src/conversions.rs index bc1bf6ef..e589b5d7 100644 --- a/bindings/python/src/conversions.rs +++ b/bindings/python/src/conversions.rs @@ -138,6 +138,11 @@ pub fn json_value_to_pyobj<'py>(py: Python<'py>, v: &Value) -> PyResult PyResult<()> { + if cpex::host::requires_rust_http_payload(hook_name) { + return Err(PyValueError::new_err( + "rate-limit hooks require an in-process Rust host", + )); + } match cpex::host::removed_hook_replacement(hook_name) { Some(replacement) => Err(PyValueError::new_err(format!( "cpex: hook '{hook_name}' was removed; use '{replacement}'" @@ -163,7 +168,7 @@ pub fn reject_removed_hook(hook_name: &str) -> PyResult<()> { /// that doesn't match the hook's payload schema. pub fn resolve_payload(hook_name: &str, value: Value) -> PyResult> { if hook_name == HOOK_IDENTITY_RESOLVE { - let idp: IdentityPayload = serde_json::from_value(value).map_err(|e| { + let idp = cpex::host::identity_input_from_value(value).map_err(|e| { PyValueError::new_err(format!( "cpex: payload for '{hook_name}' is not a valid IdentityPayload: {e}" )) @@ -220,7 +225,7 @@ pub fn serialize_payload(payload: &dyn PluginPayload) -> Option { /// /// An empty dict yields `Extensions::default()` (all fields `#[serde(default)]`). pub fn extensions_from_value(value: Value) -> PyResult { - serde_json::from_value(value) + cpex::host::extensions_input_from_value(value) .map_err(|e| PyValueError::new_err(format!("cpex: extensions conversion failed: {e}"))) } diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index 6f647ec6..be3bf677 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -24,6 +24,9 @@ use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError}; #[allow(clippy::boxed_local)] pub fn plugin_error_to_pyerr(e: Box) -> PyErr { match *e { + PluginError::Crypto { .. } => { + PyRuntimeError::new_err("cpex crypto backend validation failed") + }, PluginError::Config { message } => { PyValueError::new_err(format!("cpex config error: {message}")) }, diff --git a/bindings/python/tests/test_acceptance.py b/bindings/python/tests/test_acceptance.py index 9d0b8c00..73a54f57 100644 --- a/bindings/python/tests/test_acceptance.py +++ b/bindings/python/tests/test_acceptance.py @@ -229,7 +229,7 @@ async def test_jwt_identity_gates_a_route_by_role(tmp_path, jwks_url): [ (mint("alice", ["hr"], exp_offset=-3600), "auth.token_expired"), (mint("alice", ["hr"], secret=b"not-the-issuer-key-not-the-issuer"), "auth.signature_invalid"), - ("not-a-jwt", "auth.malformed"), + ("not-a-jwt", "auth.malformed_credential"), ], ids=["expired", "forged", "malformed"], ) diff --git a/bindings/python/tests/test_builtins.py b/bindings/python/tests/test_builtins.py index 83954514..57a1bd55 100644 --- a/bindings/python/tests/test_builtins.py +++ b/bindings/python/tests/test_builtins.py @@ -222,6 +222,7 @@ def _ciba_config(base: str) -> str: hooks: [elicit] capabilities: [perform_http] config: + store: {{kind: memory}} backchannel_endpoint: {base}/ciba/auth token_endpoint: {base}/token client_id: cpex-test @@ -342,3 +343,27 @@ async def test_refresh_secrets_without_a_secrets_block_is_empty(passthrough_engi "unchanged": 0, "failed": [], } + + +@pytest.mark.parametrize("store", ["", " store: {kind: missing-store}\n"]) +def test_ciba_requires_a_valid_explicit_store(tmp_path, store): + config = _ciba_config("http://127.0.0.1:1").replace(" store: {kind: memory}\n", store) + with pytest.raises(ValueError, match="store|variant"): + PolicyEngine(_write(tmp_path, config), allow_private_destinations=True) + + +@pytest.mark.asyncio +async def test_ciba_store_failure_halts_with_unavailable_code(tmp_path, mock_ciba): + config = _ciba_config(mock_ciba).replace( + "store: {kind: memory}", + "store: {kind: valkey, endpoint: 'redis://127.0.0.1:1', connect_timeout_ms: 100, command_timeout_ms: 100}", + ) + eng = PolicyEngine(_write(tmp_path, config), allow_private_destinations=True) + await eng.initialize() + try: + result = await _send_email(eng) + assert not result.continue_processing + assert result.violation["code"] == "elicitation.store_unavailable" + assert result.violation["proto_error_code"] == -32122 + finally: + await eng.shutdown() diff --git a/bindings/python/tests/test_conversions.py b/bindings/python/tests/test_conversions.py index 331183a3..f8ea8797 100644 --- a/bindings/python/tests/test_conversions.py +++ b/bindings/python/tests/test_conversions.py @@ -146,3 +146,26 @@ async def test_identity_result_does_not_echo_request_headers(passthrough_engine) assert "headers" not in result.modified_payload assert "not-a-real-token" not in repr(result.modified_payload) assert result.modified_payload["source"] == "bearer" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["llm_request", "metadata"]) +async def test_host_managed_extension_input_is_refused(passthrough_engine, field): + with pytest.raises(ValueError, match="requires an in-process Rust host"): + await passthrough_engine.invoke_hook("custom.hook", {}, {field: {}}) + + +@pytest.mark.asyncio +async def test_redacted_query_cannot_replace_live_input(passthrough_engine): + with pytest.raises(ValueError, match="original request"): + await passthrough_engine.invoke_hook("custom.hook", {}, {"http": {"query": "[REDACTED]"}}) + + +@pytest.mark.asyncio +async def test_history_requires_cmf_messages(passthrough_engine): + ext = {"agent": {"conversation": {"history": [{"role": "user", "content": []}]}}} + result = await passthrough_engine.invoke_hook("custom.hook", {}, ext) + assert result.continue_processing + ext["agent"]["conversation"]["history"] = ["free-form summary"] + with pytest.raises(ValueError, match="Extensions"): + await passthrough_engine.invoke_hook("custom.hook", {}, ext) diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md index 509a14c6..9c7a3ef4 100644 --- a/crates/cpex-ffi/RELEASE.md +++ b/crates/cpex-ffi/RELEASE.md @@ -8,14 +8,14 @@ without needing a Rust toolchain. This document covers what is published, how to consume and verify an artifact, and the FFI ABI policy that makes the contract durable. -For CPEX 0.3.1, use the Go module tag `go/cpex/v0.3.1` together with the -`v0.3.1` FFI artifact. Both expect C ABI **3**; upgrade them together. +For CPEX 0.4.0, use the Go module tag `go/cpex/v0.4.0` together with the +`v0.4.0` FFI artifact. Both expect C ABI **4**; upgrade them together. ## Local preparation ```sh -make ffi-package VERSION=0.3.1 # host tarball + .sha256 in dist/ -make ffi-verify VERSION=0.3.1 # checksum, metadata, ABI, Go vet + race tests +make ffi-package VERSION=0.4.0 # host tarball + .sha256 in dist/ +make ffi-verify VERSION=0.4.0 # checksum, metadata, ABI, Go vet + race tests ``` Verification uses the extracted static library from a temporary SDK copy, @@ -108,7 +108,7 @@ into your build (or fetch via `raw.githubusercontent.com` pinned to a tag) and call it before `go build` / `cargo build` / etc. ```sh -export CPEX_FFI_VERSION=v0.3.1 +export CPEX_FFI_VERSION=v0.4.0 ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" go build ./... @@ -134,7 +134,7 @@ Subsequent runs against the same version + dest are no-ops. If you want to do it by hand: ```sh -VER=v0.3.1 +VER=v0.4.0 TUPLE=linux-amd64-gnu BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" @@ -168,7 +168,7 @@ unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be augmented by the env var: ```sh -ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.3.1 bash scripts/download-ffi-artifact.sh) +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.4.0 bash scripts/download-ffi-artifact.sh) CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... ``` diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index e0aa30c8..7b6ab231 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -94,7 +94,7 @@ pub const RC_PANIC: c_int = -7; /// FFI ABI version. Bump on breaking C-surface changes; see module /// docs above for what counts as breaking. -pub const FFI_ABI_VERSION: u32 = 3; +pub const FFI_ABI_VERSION: u32 = 4; /// Returns the FFI ABI version this `libcpex_ffi` was built with. /// Language bindings call this at `init` and panic on mismatch @@ -346,8 +346,9 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result { - let idp: IdentityPayload = rmp_serde::from_slice(bytes) - .map_err(|e| format!("identity payload deserialize failed: {}", e))?; + let value = rmp_serde::from_slice(bytes) + .map_err(|_| "identity payload deserialize failed".to_owned())?; + let idp = cpex::host::identity_input_from_value(value)?; Ok(Box::new(idp)) }, _ => Err(format!("unknown payload type: {}", payload_type)), @@ -358,6 +359,10 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result Result<(), c_int> { + if cpex::host::requires_rust_http_payload(name) { + tracing::error!("{op}: rate-limit hooks require an in-process Rust host"); + return Err(RC_INVALID_INPUT); + } match cpex::host::removed_hook_replacement(name) { Some(replacement) => { tracing::error!("{}: hook '{}' was removed; use '{}'", op, name, replacement); @@ -936,7 +941,10 @@ pub unsafe extern "C" fn cpex_invoke( Some(b) => b, None => return RC_INVALID_INPUT, }; - match rmp_serde::from_slice(ext_bytes) { + match rmp_serde::from_slice(ext_bytes) + .map_err(|_| "invalid extensions MessagePack".to_owned()) + .and_then(cpex::host::extensions_input_from_value) + { Ok(e) => e, Err(e) => { tracing::error!("cpex_invoke: extensions deserialize failed: {}", e); @@ -1058,8 +1066,7 @@ pub unsafe extern "C" fn cpex_invoke( /// /// # Safety / ownership /// Identical contract to [`cpex_invoke`]: the input `context_table` is -/// consumed unconditionally (used for the hook invoke; the internal -/// identity invoke gets a fresh one); out params are null/zero on error +/// consumed unconditionally and threaded through both hooks; out params are null/zero on error /// and populated on `RC_OK`. #[unsafe(no_mangle)] pub unsafe extern "C" fn cpex_invoke_resolved( @@ -1087,9 +1094,8 @@ pub unsafe extern "C" fn cpex_invoke_resolved( *bg_handle_out = std::ptr::null_mut(); } - // Consume the input context table up front (used for the hook invoke), - // matching cpex_invoke's unconditional-consume ownership contract. - let input_ctx_table: Option = if context_table.is_null() { + // Match cpex_invoke's unconditional-consume ownership contract. + let mut input_ctx_table: Option = if context_table.is_null() { None } else { // SAFETY: `context_table` is non-null and, per the caller contract, @@ -1136,7 +1142,10 @@ pub unsafe extern "C" fn cpex_invoke_resolved( Some(b) => b, None => return RC_INVALID_INPUT, }; - match rmp_serde::from_slice(ext_bytes) { + match rmp_serde::from_slice(ext_bytes) + .map_err(|_| "invalid extensions MessagePack".to_owned()) + .and_then(cpex::host::extensions_input_from_value) + { Ok(e) => e, Err(e) => { tracing::error!("cpex_invoke_resolved: extensions deserialize failed: {}", e); @@ -1162,7 +1171,10 @@ pub unsafe extern "C" fn cpex_invoke_resolved( Some(b) => b, None => return RC_INVALID_INPUT, }; - let id_payload: IdentityPayload = match rmp_serde::from_slice(id_bytes) { + let id_payload = match rmp_serde::from_slice(id_bytes) + .map_err(|_| "invalid identity MessagePack".to_owned()) + .and_then(cpex::host::identity_input_from_value) + { Ok(p) => p, Err(e) => { tracing::error!( @@ -1185,7 +1197,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( ppe::praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE, Box::new(id_payload), base_extensions.clone(), - None, + input_ctx_table.take(), ), "cpex_invoke_resolved/identity", ) { @@ -1214,6 +1226,7 @@ pub unsafe extern "C" fn cpex_invoke_resolved( }; } + input_ctx_table = Some(id_result.context_table.clone()); match IdentityPayload::from_pipeline_result(&id_result) { Some(resolved) => resolved.apply_to_extensions(base_extensions), None => base_extensions, @@ -1965,9 +1978,9 @@ mod tests { } #[test] - fn cpex_ffi_abi_version_is_3() { - assert_eq!(cpex_ffi_abi_version(), 3); - assert_eq!(FFI_ABI_VERSION, 3); + fn cpex_ffi_abi_version_is_4() { + assert_eq!(cpex_ffi_abi_version(), 4); + assert_eq!(FFI_ABI_VERSION, 4); } #[test] diff --git a/crates/cpex-ffi/tests/egress.rs b/crates/cpex-ffi/tests/egress.rs index cc90eca7..88578380 100644 --- a/crates/cpex-ffi/tests/egress.rs +++ b/crates/cpex-ffi/tests/egress.rs @@ -9,7 +9,6 @@ mod common; -use std::collections::HashMap; use std::os::raw::{c_char, c_int}; use std::ptr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -104,10 +103,11 @@ plugins: } fn identity_payload(token: &str) -> Vec { - let headers = HashMap::from([("authorization".to_owned(), format!("Bearer {token}"))]); - let payload = IdentityPayload::new(token, TokenSource::Bearer) - .with_source_header("Authorization") - .with_headers(headers); + let payload = json!({ + "source": TokenSource::Bearer, + "source_header": "Authorization", + "headers": {"authorization": format!("Bearer {token}")}, + }); rmp_serde::to_vec_named(&payload).expect("encode identity payload") } diff --git a/crates/cpex-ffi/tests/fixtures/wire_compat.json b/crates/cpex-ffi/tests/fixtures/wire_compat.json index 3bd7a88c..fd135992 100644 --- a/crates/cpex-ffi/tests/fixtures/wire_compat.json +++ b/crates/cpex-ffi/tests/fixtures/wire_compat.json @@ -40,6 +40,10 @@ "http.request", "http.response" ], + "ratelimit": [ + "ratelimit.pre_identity", + "ratelimit.post_identity" + ], "identity": [ "identity.resolve" ], diff --git a/crates/cpex-hosts-python/README.md b/crates/cpex-hosts-python/README.md index e0d38a70..06369bdb 100644 --- a/crates/cpex-hosts-python/README.md +++ b/crates/cpex-hosts-python/README.md @@ -367,3 +367,7 @@ Two environmental caveats, both upstream of this crate: payloads to the legacy shapes for it (see the legacy worker section above). The e2e suite runs 0.1.x plugins on `cmf.tool_pre_invoke` through that translation, and checks that a message it cannot represent fails closed. + +## PPE 0.6 support boundary + +Header credential sources map to the legacy worker contract. Cookie/query raw sources are rejected. Canonical HTTP query and client address survive worker merges, but query access is not supported. Explicit `read_query`, `read_llm_request` or `read_metadata` fails before startup; `read_all` fails if its effective view includes one of those slots. Worker returns cannot replace LLM request or acquired metadata. The host uses RustCrypto and needs no FIPS provider. diff --git a/crates/cpex-hosts-python/src/credential_payloads.rs b/crates/cpex-hosts-python/src/credential_payloads.rs index 82f2580a..a2181e7a 100644 --- a/crates/cpex-hosts-python/src/credential_payloads.rs +++ b/crates/cpex-hosts-python/src/credential_payloads.rs @@ -725,18 +725,12 @@ mod tests { #[test] fn a_header_borne_credential_is_found_and_no_raw_headers_are_public() { - // The FFI and the Python bindings deserialize the payload, so the token - // arrives in the header map and `raw_token` is empty. - let payload: IdentityPayload = serde_json::from_value(json!({ - "source": "bearer", - "headers": { - "Authorization": format!("Bearer {TOKEN}"), - "x-tenant": "acme", - "X-Amzn-Oidc-Data": "gateway-jwt", - "X-Request-Id": "looks-benign-but-unvalidated" - }, - })) - .unwrap(); + let payload = IdentityPayload::new("", TokenSource::Bearer).with_headers(HashMap::from([ + ("Authorization".into(), format!("Bearer {TOKEN}")), + ("x-tenant".into(), "acme".into()), + ("X-Amzn-Oidc-Data".into(), "gateway-jwt".into()), + ("X-Request-Id".into(), "looks-benign-but-unvalidated".into()), + ])); assert_eq!(inbound_token(&payload).as_deref(), Some(TOKEN)); assert_eq!(credential_header_name(&payload), "Authorization"); assert!(public_headers(&payload).is_empty()); diff --git a/crates/cpex-hosts-python/src/credentials.rs b/crates/cpex-hosts-python/src/credentials.rs index 020776fe..41daa21e 100644 --- a/crates/cpex-hosts-python/src/credentials.rs +++ b/crates/cpex-hosts-python/src/credentials.rs @@ -432,15 +432,23 @@ fn inbound_from_token( }); } + let source_header = match &token.source { + ppe::praxis_policy_core::extensions::Credential::Header { name } => name, + _ => { + return Err(HostError::Credential { + message: "the installed Python worker supports only header credentials".into(), + }); + }, + }; Ok(InboundCredential { token: plaintext.to_string(), - source_header: token.source_header.clone(), + source_header: source_header.clone(), kind: token_kind_wire_name(&token.kind).to_string(), // Synthesized so a header-driven extractor learns which header carried // the credential. The worker scrubs the plaintext back out of these // values before a plugin can echo them onto a serialized payload. headers: Some(HashMap::from([( - token.source_header.clone(), + source_header.clone(), plaintext.to_string(), )])), }) @@ -596,7 +604,13 @@ mod tests { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt), + RawInboundToken::new( + INBOUND_SECRET, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); raw.delegated_tokens.insert( DelegationKey::new( @@ -899,7 +913,13 @@ mod tests { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + RawInboundToken::new( + token, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -945,7 +965,13 @@ mod tests { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new(" ", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + " ", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -975,7 +1001,13 @@ mod tests { // loosened it, so a direct serialize of the production types still // strips the bytes. If this ever fails, the DTO stopped being the only // path token material can take. - let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt); + let inbound = RawInboundToken::new( + INBOUND_SECRET, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ); let json = serde_json::to_string(&inbound).unwrap(); assert!( !json.contains("INBOUND-SECRET-BYTES"), @@ -1020,7 +1052,13 @@ mod tests { fn a_serialize_then_reparse_would_have_yielded_an_empty_token() { // Documents *why* the DTO reads the in-memory field directly. This is // the tempting shortcut, and it silently produces an empty credential. - let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt); + let inbound = RawInboundToken::new( + INBOUND_SECRET, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ); let round_tripped: RawInboundToken = serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap(); @@ -1028,7 +1066,28 @@ mod tests { &*round_tripped.token, "", "the guard strips the bytes on the way out" ); - assert_eq!(round_tripped.source_header, "Authorization"); + assert_eq!( + round_tripped.source, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into() + } + ); + } + + #[test] + fn non_header_sources_are_not_fabricated_as_headers() { + use ppe::praxis_policy_core::extensions::Credential; + for source in [ + Credential::Cookie { + name: "session".into(), + }, + Credential::QueryParam { + name: "token".into(), + }, + ] { + let token = RawInboundToken::new(INBOUND_SECRET, source, TokenKind::Jwt); + assert!(inbound_from_token(&token, "identity.resolve").is_err()); + } } // --- redaction ----------------------------------------------------------- @@ -1122,11 +1181,23 @@ mod tests { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::Client, - RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque), + RawInboundToken::new( + "CLIENT-TOKEN", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "X-Client".into(), + }, + TokenKind::Opaque, + ), ); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "USER-TOKEN", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -1475,12 +1546,12 @@ mod tests { #[test] fn a_header_borne_identity_credential_is_found() { - let payload: IdentityPayload = serde_json::from_value(serde_json::json!({ - "source": "bearer", - "source_header": "X-User-Token", - "headers": {"x-user-token": INBOUND_SECRET}, - })) - .unwrap(); + let payload = IdentityPayload::new("", TokenSource::Bearer) + .with_source_header("X-User-Token") + .with_headers(HashMap::from([( + "x-user-token".into(), + INBOUND_SECRET.into(), + )])); let mut task = task(); attach_credential_for_payload( &mut task, diff --git a/crates/cpex-hosts-python/src/extensions.rs b/crates/cpex-hosts-python/src/extensions.rs index 59596aac..c722b700 100644 --- a/crates/cpex-hosts-python/src/extensions.rs +++ b/crates/cpex-hosts-python/src/extensions.rs @@ -117,6 +117,8 @@ fn strip_sensitive(headers: &HashMap) -> HashMap /// because the plugin needs them for routing decisions. fn sanitize_http(http: &HttpExtension) -> HttpExtension { HttpExtension { + query: None, + client_address: http.client_address.clone(), request_headers: public_http_headers(&http.request_headers), secret_headers: http.secret_headers.clone(), response_headers: public_http_headers(&http.response_headers), @@ -143,6 +145,10 @@ fn sanitize_http(http: &HttpExtension) -> HttpExtension { /// policies into no-ops downstream. fn returned_http(returned: &HttpExtension, inbound: Option<&HttpExtension>) -> HttpExtension { HttpExtension { + query: inbound.and_then(|h| h.query.clone()), + client_address: inbound + .map(|h| h.client_address.clone()) + .unwrap_or_default(), request_headers: strip_sensitive(&returned.request_headers), secret_headers: inbound .map(|h| h.secret_headers.clone()) @@ -194,6 +200,17 @@ pub fn attach_extensions(task: &mut Value, extensions: &Extensions) -> Result<() /// structs evolve. Only `http` is rewritten on the way out, to default-deny /// unvalidated raw header values. fn to_wire(extensions: &Extensions) -> Result { + if extensions.llm_request.is_some() + || extensions.metadata.is_some() + || extensions + .http + .as_ref() + .is_some_and(|http| http.query.is_some()) + { + return Err(HostError::Protocol { + message: "the installed Python worker cannot represent query, LLM request or acquired metadata access".into(), + }); + } let mut map = serde_json::Map::new(); let mut put = |key: &str, value: Result| -> Result<(), HostError> { @@ -294,6 +311,15 @@ pub fn owned_from_returned_slot( ), })?; + if ["llm_request", "metadata"] + .iter() + .any(|key| object.get(*key).is_some_and(|v| !v.is_null())) + { + return Err(HostError::Protocol { + message: "worker results cannot replace LLM request or acquired metadata".into(), + }); + } + // Start from the inbound view: immutable slots keep their original `Arc` // pointers, and the write tokens the executor issued are carried over. let mut owned = inbound.cow_copy(); @@ -652,7 +678,13 @@ mod tests { let mut inbound_tokens = HashMap::new(); inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("super-secret", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "super-secret", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { @@ -941,6 +973,8 @@ mod tests { inbound_http.path = Some("/api/v1/transfer".into()); inbound_http.host = Some("bank.internal".into()); inbound_http.scheme = Some("https".into()); + inbound_http.query = Some("token=secret".into()); + inbound_http.set_client_address("[::ffff:192.0.2.1]:443"); // What a worker actually returns: headers only, request line absent. let returned_absent = HttpExtension { @@ -952,6 +986,8 @@ mod tests { assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer")); assert_eq!(merged.host.as_deref(), Some("bank.internal")); assert_eq!(merged.scheme.as_deref(), Some("https")); + assert_eq!(merged.query.as_deref(), Some("token=secret")); + assert_eq!(merged.client_address(), Some("192.0.2.1")); assert_eq!( merged.request_headers.get("X-Scanned").map(String::as_str), Some("1") @@ -959,6 +995,10 @@ mod tests { // And a worker that *does* send a request line cannot override it. let returned_hostile = HttpExtension { + query: Some("forged=1".into()), + client_address: ppe::praxis_policy_core::extensions::ClientAddress::parse( + "198.51.100.1", + ), method: Some("GET".into()), path: Some("/healthz".into()), host: Some("evil.example".into()), diff --git a/crates/cpex-hosts-python/src/plugin.rs b/crates/cpex-hosts-python/src/plugin.rs index 1bf7fe47..b6becde5 100644 --- a/crates/cpex-hosts-python/src/plugin.rs +++ b/crates/cpex-hosts-python/src/plugin.rs @@ -347,6 +347,14 @@ impl RespawnState { impl IsolatedPythonPlugin { /// Build a plugin from its config entry, parsing the venv settings. pub fn from_config(config: &PluginConfig) -> Result> { + for capability in ["read_query", "read_llm_request", "read_metadata"] { + if config.capabilities.contains(capability) { + return Err(PluginError::Config { + message: format!("the installed Python worker cannot honor '{capability}'"), + } + .boxed()); + } + } let venv_config = VenvConfig::from_plugin_config(config)?; // Not read from the config block — see `DEFAULT_PLUGIN_DIR`. A @@ -1487,6 +1495,18 @@ mod tests { ); } + #[test] + fn unsupported_worker_capabilities_fail_before_startup() { + for capability in ["read_query", "read_llm_request", "read_metadata"] { + let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"})); + config.capabilities.insert(capability.into()); + let err = IsolatedPythonPlugin::from_config(&config) + .err() + .expect("unsupported capability"); + assert!(err.to_string().contains(capability)); + } + } + #[test] fn under_the_shim_cmf_hooks_reach_the_worker_under_legacy_names() { let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"})); @@ -2276,7 +2296,13 @@ while True: let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "E2E-SECRET-TOKEN", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -2373,7 +2399,13 @@ while True: let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "E2E-SECRET-TOKEN", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -2629,7 +2661,13 @@ while True: let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "RESPAWN-SECRET", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), @@ -2693,7 +2731,13 @@ while True: let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt), + RawInboundToken::new( + "MUST-NOT-LEAK", + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); let extensions = Extensions { raw_credentials: Some(Arc::new(raw)), diff --git a/crates/cpex-hosts-python/tests/acceptance_e2e.rs b/crates/cpex-hosts-python/tests/acceptance_e2e.rs index 29b745e6..b747b118 100644 --- a/crates/cpex-hosts-python/tests/acceptance_e2e.rs +++ b/crates/cpex-hosts-python/tests/acceptance_e2e.rs @@ -364,7 +364,13 @@ async fn python_identity_and_delegation_plugins_serve_a_ppe_route() { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new(SECRET, "authorization", TokenKind::Opaque), + RawInboundToken::new( + SECRET, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "authorization".into(), + }, + TokenKind::Opaque, + ), ); extensions.raw_credentials = Some(Arc::new(raw)); diff --git a/crates/cpex-hosts-python/tests/credential_e2e.rs b/crates/cpex-hosts-python/tests/credential_e2e.rs index 71d05652..1d53d89b 100644 --- a/crates/cpex-hosts-python/tests/credential_e2e.rs +++ b/crates/cpex-hosts-python/tests/credential_e2e.rs @@ -148,7 +148,13 @@ fn extensions_with_token() -> Extensions { let mut raw = RawCredentialsExtension::default(); raw.inbound_tokens.insert( TokenRole::User, - RawInboundToken::new(SECRET, "Authorization", TokenKind::Jwt), + RawInboundToken::new( + SECRET, + ppe::praxis_policy_core::extensions::Credential::Header { + name: "Authorization".into(), + }, + TokenKind::Jwt, + ), ); Extensions { raw_credentials: Some(Arc::new(raw)), diff --git a/crates/cpex/src/host.rs b/crates/cpex/src/host.rs index 3eb38f37..208c370f 100644 --- a/crates/cpex/src/host.rs +++ b/crates/cpex/src/host.rs @@ -158,6 +158,47 @@ pub fn resolved_identity_fields(payload: &IdentityPayload) -> Vec<&'static str> set } +/// Decode binding input without discarding credential headers. +pub fn identity_input_from_value(mut value: serde_json::Value) -> Result { + let object = value + .as_object_mut() + .ok_or("identity input must be an object")?; + let headers = match object.remove("headers") { + None => std::collections::HashMap::new(), + Some(value) => serde_json::from_value(value) + .map_err(|_| "identity headers must map strings to strings")?, + }; + let payload: IdentityPayload = + serde_json::from_value(value).map_err(|_| "invalid IdentityPayload input")?; + Ok(payload.with_headers(headers)) +} + +/// Whether a hook requires an HTTP payload unavailable in the bindings. +pub fn requires_rust_http_payload(hook: &str) -> bool { + matches!(hook, "ratelimit.pre_identity" | "ratelimit.post_identity") +} + +/// Decode binding extensions, refusing fields that cannot cross this boundary. +pub fn extensions_input_from_value( + value: serde_json::Value, +) -> Result { + for field in ["llm_request", "metadata"] { + if value.get(field).is_some_and(|v| !v.is_null()) { + return Err(format!("'{field}' requires an in-process Rust host")); + } + } + if value + .pointer("/http/query") + .and_then(serde_json::Value::as_str) + == Some("[REDACTED]") + { + return Err( + "http.query must come from the original request, not serialized extensions".into(), + ); + } + serde_json::from_value(value).map_err(|_| "invalid Extensions input".into()) +} + /// The identity result visible to language bindings. Request headers may /// contain credentials, so they never appear in a returned payload. pub fn identity_result_value( @@ -209,6 +250,48 @@ impl From for RefreshSummary { mod tests { use super::*; + #[test] + fn binding_identity_keeps_headers_and_redacts_results() { + let payload = identity_input_from_value(serde_json::json!({ + "source": "bearer", + "source_header": "X-User-Token", + "headers": {"X-User-Token": "secret", "Authorization": "Bearer other"}, + "client_host": "192.0.2.1", + "client_port": 443, + })) + .unwrap(); + assert_eq!(payload.headers()["X-User-Token"], "secret"); + assert_eq!(payload.headers()["Authorization"], "Bearer other"); + assert_eq!(payload.source_header(), Some("X-User-Token")); + assert_eq!(payload.client_port(), Some(443)); + let result = identity_result_value(&payload).unwrap(); + assert!(result.get("headers").is_none()); + assert!(!result.to_string().contains("secret")); + } + + #[test] + fn invalid_identity_headers_do_not_appear_in_errors() { + let err = identity_input_from_value(serde_json::json!({ + "source": "bearer", "headers": {"secret": ["credential"]}, + })) + .unwrap_err(); + assert!(!err.contains("secret")); + assert!(!err.contains("credential")); + } + + #[test] + fn binding_extensions_refuse_unrepresentable_inputs() { + for value in [ + serde_json::json!({"metadata": {}}), + serde_json::json!({"llm_request": {"body": {}}}), + serde_json::json!({"http": {"query": "[REDACTED]"}}), + ] { + assert!(extensions_input_from_value(value).is_err()); + } + let ext = extensions_input_from_value(serde_json::json!({"http": {"query": ""}})).unwrap(); + assert_eq!(ext.http.unwrap().query.as_deref(), Some("")); + } + #[test] fn private_destination_warning_reaches_hosts_without_a_subscriber() { let mut output = Vec::new(); diff --git a/crates/cpex/tests/facade.rs b/crates/cpex/tests/facade.rs index cfcb3385..1212a99c 100644 --- a/crates/cpex/tests/facade.rs +++ b/crates/cpex/tests/facade.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use cpex::PolicyEngine; +use cpex::praxis_policy_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension}; use cpex::prelude::*; /// Load a one-plugin config on an engine with the builtins installed and @@ -127,3 +128,59 @@ async fn cmf_tool_pre_invoke_config_loads_and_dispatches() { engine.shutdown().await; } + +#[tokio::test] +async fn settled_apl_predicates_survive_an_unorderable_operand() { + for predicate in [ + "require(role.hr | args.amount <= 10000)", + "require(args.amount <= 10000 | role.hr)", + "require(!(role.guest & args.amount > 10000))", + "require(!(args.amount > 10000 & role.guest))", + ] { + let engine = Arc::new(PolicyEngine::default()); + cpex::install_builtins(&engine); + engine.load_config_yaml(&format!( + "routes:\n - tool: lookup\n authorization:\n pre_invocation: ['{predicate}']\n" + )).unwrap(); + engine.initialize().await.unwrap(); + let ext = Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("lookup".into()), + ..Default::default() + })), + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + roles: ["hr".into()].into(), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + let payload = MessagePayload { + message: Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "settled".into(), + name: "lookup".into(), + arguments: [("amount".into(), serde_json::json!("NaN"))].into(), + namespace: None, + }, + }], + ), + }; + let (result, bg) = engine + .invoke_by_name("cmf.tool_pre_invoke", Box::new(payload), ext, None) + .await; + bg.wait_for_background_tasks().await; + assert!( + result.continue_processing, + "{predicate}: {:?}", + result.violation + ); + engine.shutdown().await; + } +} diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index 4325b86a..a053fa9b 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -173,4 +173,4 @@ Two blocks sit alongside `authorization:` on the same sections and are not polic - `assertions:` renders engine-derived identity onto the upstream request as headers, and filters what an upstream may tell a client back. See [Header Assertions]({{< relref "/docs/assertions" >}}). - `authentication:` names the identity-resolution plugins that run before policy. Under the default `dispatch: policy`, a route with no `authentication:` at any scope runs no identity resolution. See [Identity]({{< relref "/docs/apl/identity" >}}). -Every fragment on this page is drawn from the PPE APL parser tests and the reference deployments, so the forms shown here parse as written. PPE's [APL grammar](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/apl/apl-grammar.md) is the normative syntax. +Every fragment on this page is drawn from the PPE APL parser tests and the reference deployments, so the forms shown here parse as written. PPE's [APL grammar](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/apl/apl-grammar.md) is the normative syntax. diff --git a/docs/content/docs/apl/elicitation.md b/docs/content/docs/apl/elicitation.md index 1c765809..cc3b3164 100644 --- a/docs/content/docs/apl/elicitation.md +++ b/docs/content/docs/apl/elicitation.md @@ -67,6 +67,7 @@ plugins: kind: file path: /etc/cpex-secrets/client-secret approver_claim: preferred_username + store: { kind: memory } ``` `from` becomes the CIBA `login_hint`, `purpose` seeds the `binding_message`, and `timeout` maps to `requested_expiry`. Dispatch returns an `auth_req_id` that doubles as the elicitation id the agent echoes on retry. @@ -92,7 +93,7 @@ Two independent checks stand between an approval and the tool call, and it is wo The `purpose` is recorded verbatim as the source of truth for what was approved; it is never derived from model output. -**Approval binding.** PPE 0.5.0 binds CIBA approvals to the authenticated requester, route and tool, with atomic single-use redemption within a plugin instance. A requester must have a non-empty subject id before dispatch. Keep `timeout` short and return elicitation ids only to the requesting client. Replicas need sticky routing because the correlation store is in process. +**Approval binding.** PPE binds CIBA approvals to the authenticated requester, route and tool, with atomic single-use redemption within a plugin instance. A requester must have a non-empty subject id before dispatch. Keep `timeout` short and return elicitation ids only to the requesting client. The `memory` store is local to the plugin instance and needs sticky routing across replicas. Use an explicitly configured `valkey` store for shared correlation state. Missing stores and unknown config keys fail at load. A storage outage fails closed as `elicitation.store_unavailable`, mapped to JSON-RPC `-32122`. ## How it connects to the pipeline diff --git a/docs/content/docs/apl/identity.md b/docs/content/docs/apl/identity.md index 9b2ab852..1dab19ac 100644 --- a/docs/content/docs/apl/identity.md +++ b/docs/content/docs/apl/identity.md @@ -23,7 +23,7 @@ plugins: capabilities: [perform_http] # fetches the issuer's JWKS config: role: user - header: X-User-Token + credential: { kind: header, name: X-User-Token } trusted_issuers: - issuer: "https://idp.example.com/realms/agents" audiences: ["cpex-gateway"] @@ -70,7 +70,7 @@ So `require(role.hr)` is true when the verified token carried the `hr` role, and ## Multiple sources -A request often carries more than one identity: the end user, the calling application, and the calling workload. Register an identity plugin per source. The bundled JWT plugin takes a `role` (`user`, `client`, or `caller_workload`) and a `header`, so each inbound credential resolves into its own namespace: +A request often carries more than one identity: the end user, the calling application, and the calling workload. Register an identity plugin per source. The bundled JWT plugin takes a `role` (`user`, `client`, or `caller_workload`) and a typed `credential` location, so each inbound credential resolves into its own namespace: | `role` | Namespace | Typical credential | |--------|-----------|--------------------| @@ -92,7 +92,7 @@ plugins: capabilities: [perform_http] config: role: caller_workload - header: X-Workload-Token + credential: { kind: header, name: X-Workload-Token } trusted_issuers: - issuer: "https://spire.example.com" audiences: ["cpex-gateway"] diff --git a/docs/content/docs/apl/pdp.md b/docs/content/docs/apl/pdp.md index c517d8a1..d3996578 100644 --- a/docs/content/docs/apl/pdp.md +++ b/docs/content/docs/apl/pdp.md @@ -94,7 +94,7 @@ authorization: - cel: { expr: "subject.department == 'compliance' || 'admin' in subject.roles" } ``` -PPE 0.5.0 uses `cel` 0.15. Use `in` for list membership; container `.contains()` and the default `min` / `max` functions are not available. String `size()` counts Unicode code points rather than UTF-8 bytes, and `exists()` can return true after another element errors. Review policies depending on byte limits or predicate errors. `CelResolver::with_functions` and its callback now return `Result`; conflicting standard-library overloads fail construction. +PPE 0.6.0 uses `cel` 0.15. Use `in` for list membership; container `.contains()` and the default `min` / `max` functions are not available. String `size()` counts Unicode code points rather than UTF-8 bytes, and `exists()` can return true after another element errors. Review policies depending on byte limits or predicate errors. `CelResolver::with_functions` and its callback now return `Result`; conflicting standard-library overloads fail construction. All three builtin resolvers ship in every CPEX surface: the Rust facade, the Python package, the Go module and the C ABI. diff --git a/docs/content/docs/assertions.md b/docs/content/docs/assertions.md index c396021d..fa434f8a 100644 --- a/docs/content/docs/assertions.md +++ b/docs/content/docs/assertions.md @@ -173,6 +173,6 @@ When a block is configured, the engine logs the whole boundary as one document a ## Go deeper -- PPE's [assertions wire contract](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/assertions.md), the source for this page. It also covers nested dispatch through `invoke_entries` and the `http:` route obligations in detail. +- PPE's [assertions wire contract](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/assertions.md), the source for this page. It also covers nested dispatch through `invoke_entries` and the `http:` route obligations in detail. - [Identity & Delegation]({{< relref "/docs/identity-delegation" >}}): where the subject, client and claims come from. - [Deployment]({{< relref "/docs/deployment" >}}): where the boundary sits. diff --git a/docs/content/docs/auditing.md b/docs/content/docs/auditing.md index ae8aaac0..b1ccfdee 100644 --- a/docs/content/docs/auditing.md +++ b/docs/content/docs/auditing.md @@ -20,12 +20,12 @@ Both are off until you configure them. A plugin behaves the same either way: tur |---|---|---|---| | Effect log (`engine_settings.effect_log_path`) and recovery at `initialize` | yes | yes | yes | | Content provenance digests, stream namespace | yes | yes | yes | -| Audit sinks (plugins that receive every decision and effect) | yes, see the note below | no | only through a Rust factory registered with `RegisterFactories` | +| Audit sinks (plugins that receive every decision and effect) | yes | no | only through a Rust factory registered with `RegisterFactories` | | Custom reconciler (`recover_effects_with`) | yes | no | no | Engine settings are part of the policy document, so every surface reads them. Sinks are Rust plugins, and CPEX ships none: PPE's `audit/logger` is a reference plugin, not a builtin. A Rust host that wants it copies its source from the PPE repository into its own workspace, builds it against the `praxis-policy` facade, and registers it under a `kind` of its choosing. -**Sinks and routes in this release.** Under the default `dispatch: policy`, PPE 0.5.0 fails the load for any declared plugin that no step names, and that check includes sinks. A sink naming no hooks next to APL `routes:` fails with "no step names ``, so it can never run". Naming it with `run()` does not help: the step has no handler to call, so the request is denied with `plugin:`. Until PPE changes this, a document with routes cannot attach a sink through `plugins:`. To log each call from policy, use a hook plugin that a step names, as the tutorial's [`tutorial/audit-line`]({{< relref "/docs/tutorial/04-effects" >}}) plugin does. Hosts on every surface can also log the `PipelineResult` each invocation returns, which carries the verdict and violation code. +**Sinks and routes.** PPE 0.6.0 loads enabled hookless audit sinks beside APL `routes:` under `dispatch: policy`. Declare the sink in `plugins:` with no hooks; it observes invocations without a `run(...)` step. Reference audit loggers require an explicit `destination:` and transcript recorders require `exporters:`. CPEX does not bundle these reference plugins. Hosts on every surface can also record the `PipelineResult` returned by each call. ## What the engine promises @@ -173,6 +173,6 @@ Grant a sink exactly the capabilities for what it records (`read_subject`, `read ## Go deeper -- PPE's [auditing guide](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/auditing.md), the source for this page, with the full record schemas and the effect protocol for plugin authors (`perform_effect`, `begin_effect`, `complete_effect`). +- PPE's [auditing guide](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/auditing.md), the source for this page, with the full record schemas and the effect protocol for plugin authors (`perform_effect`, `begin_effect`, `complete_effect`). - [Header Assertions]({{< relref "/docs/assertions" >}}): the contract that can refuse a request after the pipeline. - [Configuration]({{< relref "/docs/configuration" >}}): the `secrets:` block. diff --git a/docs/content/docs/builtins.md b/docs/content/docs/builtins.md index 0f757dbc..469e20a9 100644 --- a/docs/content/docs/builtins.md +++ b/docs/content/docs/builtins.md @@ -7,7 +7,7 @@ weight: 70 CPEX ships a set of plugins, decision points, a session store and secret providers, all from the praxis-policy engine (PPE) builtins. Every CPEX surface carries the same set: the Rust facade, the Python package, the Go module and the C ABI. A config names a builtin by `kind`. -## The 0.3.0 catalog +## The 0.4.0 catalog | Kind | Type | Purpose | |------|------|---------| @@ -38,7 +38,7 @@ CPEX also ships one plugin of its own: the Biscuit delegator (`cpex-plugin-deleg | Go | `engine.EnableAPL(opts...)` | all of the above | | C ABI | `cpex_apl_install(handle, flags)` | all of the above | -There are no Cargo features to choose. `cpex = "0.3"` compiles every builtin in. The facade's only feature is `python-host`, which adds the out-of-process Python plugin host. +There are no Cargo features to choose. `cpex = "0.4"` compiles every builtin in. The facade's only feature is `python-host`, which adds the out-of-process Python plugin host. ## The HTTP transport @@ -79,8 +79,8 @@ What to use instead: A Rust host can still register PPE's reference implementations. They are `publish = false`, so there is no crates.io package to depend on. Copy their source into your own workspace instead: -1. Copy [`reference/plugins/pii-scanner`](https://github.com/praxis-proxy/policy/tree/v0.5.0/reference/plugins/pii-scanner) or [`reference/plugins/audit-logger`](https://github.com/praxis-proxy/policy/tree/v0.5.0/reference/plugins/audit-logger) from the PPE `v0.5.0` tag into your workspace, for example `plugins/pii-scanner`. -2. In its `Cargo.toml`, replace every inherited `workspace = true` field and dependency with an explicit value (the crates use edition 2024), and replace the core dependency with `ppe = { package = "praxis-policy", version = "=0.5.0", default-features = false }`. Update `praxis_policy_core::` imports to `ppe::praxis_policy_core::` so the plugin uses the same facade version CPEX pins. +1. Copy [`reference/plugins/pii-scanner`](https://github.com/praxis-proxy/policy/tree/v0.6.0/reference/plugins/pii-scanner) or [`reference/plugins/audit-logger`](https://github.com/praxis-proxy/policy/tree/v0.6.0/reference/plugins/audit-logger) from the PPE `v0.6.0` tag into your workspace, for example `plugins/pii-scanner`. +2. In its `Cargo.toml`, replace every inherited `workspace = true` field and dependency with an explicit value (the crates use edition 2024), and replace the core dependency with `ppe = { package = "praxis-policy", version = "=0.6.0", default-features = false, features = ["crypto-rustcrypto"] }`. Update `praxis_policy_core::` imports to `ppe::praxis_policy_core::` so the plugin uses the same facade version CPEX pins. 3. Register its factory before loading the config: ```rust @@ -118,7 +118,7 @@ plugins: capabilities: [perform_http] config: role: user - header: X-User-Token + credential: { kind: header, name: X-User-Token } trusted_issuers: [ ... ] global: diff --git a/docs/content/docs/cmf-extensions.md b/docs/content/docs/cmf-extensions.md index 2b7d2299..540c6def 100644 --- a/docs/content/docs/cmf-extensions.md +++ b/docs/content/docs/cmf-extensions.md @@ -161,12 +161,20 @@ Per-hop scopes, audience and strategy stay on the typed chain. | Key | Type | When | |---|---|---| | `http.method`, `http.path`, `http.host`, `http.scheme` | String | set | +| `http.query` | String | present, including an empty query; requires `read_query` | +| `http.client_address` | String | trusted normalized peer address set | | `http.status` | Int | set (response half) | | `http.request_headers.`, `http.response_headers.` | String | each header, name lowercased | ### `llm` -`llm.model_id`, `llm.provider` (String, set) and `llm.capabilities` (String set, always). The parsed request body is the separate `llm_request` slot: OPA and CEL read it as `llm.request`, Cedar as `context.llm.request`. +Canonical model keys are `llm.model.id`, `llm.model.provider` (String, set) and `llm.model.capabilities` (String set, always). The earlier `llm.model_id`, `llm.provider` and `llm.capabilities` aliases remain available. + +A Rust host's normalized `llm_request` parts populate `llm.input.*`: offered tools, stop sequences, tool choice, forced tool, token/candidate limits, sampling parameters, streaming and instruction digest. Instruction text stays on the typed carrier. OPA and CEL read the body as `llm.request`, Cedar as `context.llm.request`. Python/Go generic inputs cannot install this carrier. + +### Acquired metadata + +Rust hosts install acquired facts through PPE's framework API. Values appear under `metadata..*`, with provenance leaves such as `$source`, `$outcome`, `$cache` and `$fetched_at`. The slot requires `read_metadata`, is immutable to ordinary plugin writes, and is cleared by `for_child_request()`. ### `mcp` @@ -183,6 +191,8 @@ Schemas and annotations are not flattened. ### `completion` +These aliases remain available alongside canonical `llm.output.*` keys with the same suffixes. + | Key | Type | When | |---|---|---| | `completion.stop_reason` | String | set | @@ -215,6 +225,6 @@ On `tool:` routes, OPA and CEL do not read the flattened `args.*` keys. They get ## Go deeper -- PPE's [CMF extensions contract](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/cmf-extensions.md), the source for this page. +- PPE's [CMF extensions contract](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/cmf-extensions.md), the source for this page. - [Common Message Format]({{< relref "/docs/cmf" >}}): the message the extensions travel with. - [Identity Claim Mapping]({{< relref "/docs/identity-claim-mapping" >}}): how token claims become `subject.*` and `client.*`. diff --git a/docs/content/docs/crates.md b/docs/content/docs/crates.md index b87bd581..984549b4 100644 --- a/docs/content/docs/crates.md +++ b/docs/content/docs/crates.md @@ -24,7 +24,7 @@ Most Rust hosts depend on `cpex` and nothing else. ## PPE crates the facade re-exports -The workspace uses one PPE dependency: `ppe`, an alias for the `praxis-policy` facade pinned to `=0.5.0`. Members access core and APL types through its re-exports. CPEX upgrades the pin deliberately and reruns its parity suite when it does. +The workspace uses one PPE dependency: `ppe`, an alias for the `praxis-policy` facade pinned to `=0.6.0`. Members access core and APL types through its re-exports. CPEX upgrades the pin deliberately and reruns its parity suite when it does. | PPE crate | Reached as | Role | |---|---|---| @@ -34,7 +34,7 @@ The workspace uses one PPE dependency: `ppe`, an alias for the `praxis-policy` f | `praxis-policy-apl-cmf` | `cpex::praxis_policy_apl_cmf` | Bridges typed extensions into the flat attribute bag policy reads. See [CMF Extensions]({{< relref "/docs/cmf-extensions" >}}). | | `praxis-policy-apl-runtime` | `cpex::praxis_policy_apl_runtime` | Wires APL routes to hooks and dispatches plugins and decision points. | | `praxis-policy-orchestration` | (internal) | Async branch-concurrency primitives the runtime uses. | -| `praxis-policy-builtins` | factory types on `cpex::*` | The bundled extensions, one crate with a feature each. `cpex` turns on all of them. | +| `praxis-policy-builtins` | factory types on `cpex::*` | The bundled extensions, one crate with a feature each. `cpex` enables every stable builtin, with RustCrypto for cryptography. FIPS and experimental rate limiting are not enabled. | The builtins `cpex::install_builtins` registers: @@ -42,7 +42,7 @@ The builtins `cpex::install_builtins` registers: |---|---| | `identity/jwt` | identity resolver | | `identity/api-key` | identity resolver | -| `delegator/oauth` | token delegator | +| `delegator/oauth`, `delegator/vault` | token delegators | | `elicitation/ciba` | elicitation channel | | `cedar-direct`, `cel`, `opa` | PDP resolvers | | `valkey` | session store | @@ -51,7 +51,7 @@ The Vault KV v2 secret provider (`vault`) is compiled in too, but it is not a pl ## Reference plugins -PPE keeps a PII scanner and an audit logger as reference plugins. They are not published and not bundled, so no CPEX surface registers them. A Rust host that wants one copies its source from the PPE repository into its own workspace, builds it against the `praxis-policy` facade from crates.io, and registers its factory under a `kind` of its choosing. The Python and Go bindings do not include them. See [Auditing]({{< relref "/docs/auditing" >}}) for what an audit sink needs. +PPE keeps a PII scanner, audit logger and transcript recorder as reference plugins. They are not published and not bundled, so no CPEX surface registers them. A Rust host that wants one copies its source from the PPE repository into its own workspace, builds it against the `praxis-policy` facade from crates.io, and registers its factory under a `kind` of its choosing. The Python and Go bindings do not include them. See [Auditing]({{< relref "/docs/auditing" >}}) for what an audit sink needs. ## Writing a plugin @@ -60,5 +60,5 @@ There is no separate SDK crate. The plugin surface is `cpex::prelude`: the `Plug ## API docs - [docs.rs/cpex](https://docs.rs/cpex) for the facade. -- [docs.rs/praxis-policy/0.5.0](https://docs.rs/praxis-policy/0.5.0) and [docs.rs/praxis-policy-core/0.5.0](https://docs.rs/praxis-policy-core/0.5.0) for the engine items the facade re-exports. -- PPE's [crate reference](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/crates.md) for the engine's own layout. +- [docs.rs/praxis-policy/0.6.0](https://docs.rs/praxis-policy/0.6.0) and [docs.rs/praxis-policy-core/0.6.0](https://docs.rs/praxis-policy-core/0.6.0) for the engine items the facade re-exports. +- PPE's [crate reference](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/crates.md) for the engine's own layout. diff --git a/docs/content/docs/extensions.md b/docs/content/docs/extensions.md index 318a5779..ee6017bd 100644 --- a/docs/content/docs/extensions.md +++ b/docs/content/docs/extensions.md @@ -23,9 +23,10 @@ Each extension flattens into bag attributes under its namespace, gated by a read | Agent | session, conversation, turn, and lineage context | `agent.*` | `read_agent` | | Meta | entity metadata: type, name, tags, scope, properties | `meta.*` | `read_meta` | | Request | environment, request id, timestamp, trace and span ids | `request.*` | `read_request` | -| HTTP | request line (method, path, host, scheme) and request/response headers (lowercased) | `http.method`, `http.path`, `http.host`, `http.scheme`, `http.request_headers.*`, `http.response_headers.*` | `read_headers`, `write_headers` | +| HTTP | request line, normalized peer address, raw query and request/response headers | `http.*` | `read_headers`, `read_query`, `write_headers` | | LLM | model id, provider, capabilities | `llm.*` | `read_llm` | -| LLM request | the host-parsed inference request body | not flattened; OPA, CEL and Cedar read it as `llm.request` | `read_llm_request` | +| LLM request | the host-parsed inference request body and normalized parts | `llm.input.*`; PDPs also read the body as `llm.request` | `read_llm_request` | +| Acquired metadata | framework-installed facts and provenance per acquisition instance | `metadata..*` | `read_metadata` | | MCP | tool, resource, or prompt metadata | `mcp.*` (`mcp.tool.*`, `mcp.resource.*`, `mcp.prompt.*`) | `read_mcp` | | Completion | stop reason, token counts, model, latency | `completion.*` | `read_completion` | | Provenance | source, message id, parent id | `provenance.*` | `read_provenance` | @@ -38,6 +39,8 @@ The request arguments and response body are also flattened. An object writes `ar Most extensions are **inputs**: resolved before policy runs and flattened into the bag for predicates to read. The **candidate constraint** is the exception: it is an **output**. APL `restrict` effects fold into it (see [Backend Restriction]({{< relref "/docs/apl/restrict" >}})), it rides the returned extensions the same way minted delegation tokens do, and the host router reads it typed to prune its candidate set. +The LLM request and acquired metadata need Rust host installation APIs. Python/Go reject non-null generic inputs for them, and the isolated Python worker rejects those read capabilities and query access. See the [0.4 migration guide]({{< relref "/docs/migration-0.4#support-boundaries" >}}). Serialized queries are redacted; retain the live value from the original request between hooks. + ## Capabilities A plugin declares the capabilities it needs. CPEX filters the extensions before handing them to the plugin, so a plugin sees only what it declared. The default is no access; capabilities are additive grants. @@ -69,8 +72,10 @@ plugins: | `read_meta` | `meta.*` | | `read_request` | `request.*` | | `read_headers` | `http.method`, `http.path`, `http.host`, `http.scheme`, `http.request_headers.*`, `http.response_headers.*` | +| `read_query` | live `http.query`, without the leading `?` | | `read_llm` | `llm.*` | -| `read_llm_request` | no bag keys; gates the parsed request body on the LLM request extension | +| `read_llm_request` | normalized `llm.input.*` and the typed parsed request body | +| `read_metadata` | acquired `metadata..*` values and framework provenance | | `read_mcp` | `mcp.*` | | `read_completion` | `completion.*` | | `read_provenance` | `provenance.*` | diff --git a/docs/content/docs/http-routing.md b/docs/content/docs/http-routing.md index 58916111..48d86147 100644 --- a/docs/content/docs/http-routing.md +++ b/docs/content/docs/http-routing.md @@ -138,6 +138,6 @@ routes: ## Go deeper -- PPE's [HTTP routing guide](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/http-routing.md), the source for this page. +- PPE's [HTTP routing guide](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/http-routing.md), the source for this page. - [Header Assertions]({{< relref "/docs/assertions" >}}): control the identity and response headers that cross the boundary on these routes. - [Configuration]({{< relref "/docs/configuration" >}}): the full route schema. diff --git a/docs/content/docs/identity-claim-mapping.md b/docs/content/docs/identity-claim-mapping.md index fa51cead..6e355a91 100644 --- a/docs/content/docs/identity-claim-mapping.md +++ b/docs/content/docs/identity-claim-mapping.md @@ -102,11 +102,11 @@ config: ## API keys -`identity/api-key` resolves an opaque key against a file or HTTP directory and maps the record's fields with the same mapping machinery, under `record_map:`. See PPE's [identity and delegation guide](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/identity-delegation.md#recipe-7-an-opaque-api-key-resolved-against-a-directory) for the full config and its denial codes. +`identity/api-key` resolves an opaque key against a file or HTTP directory and maps the record's fields with the same mapping machinery, under `record_map:`. See PPE's [identity and delegation guide](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/identity-delegation.md#recipe-7-an-opaque-api-key-resolved-against-a-directory) for the full config and its denial codes. ## A preset for another IdP -Presets are JSON files compiled into PPE's `identity/jwt` builtin, so a new one is a PPE change, not a CPEX one. Prototype it as an inline `claim_map` first: the schema is identical, and the map works today. To contribute it, follow PPE's [guide to adding a claim mapper preset](https://github.com/praxis-proxy/policy/blob/v0.5.0/docs/content/identity-claim-mapping.md). It ships to CPEX when CPEX moves its PPE pin. +Presets are JSON files compiled into PPE's `identity/jwt` builtin, so a new one is a PPE change, not a CPEX one. Prototype it as an inline `claim_map` first: the schema is identical, and the map works today. To contribute it, follow PPE's [guide to adding a claim mapper preset](https://github.com/praxis-proxy/policy/blob/v0.6.0/docs/content/identity-claim-mapping.md). It ships to CPEX when CPEX moves its PPE pin. ## Go deeper diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md index 2155a1f4..4fa32666 100644 --- a/docs/content/docs/identity-delegation.md +++ b/docs/content/docs/identity-delegation.md @@ -195,7 +195,7 @@ plugins: capabilities: [perform_http] # fetches the IdP's JWKS config: role: user - header: X-User-Token + credential: { kind: header, name: X-User-Token } trusted_issuers: - issuer: "https://idp.example.com/realms/corp" audiences: ["cpex"] # the audience the user token is minted for @@ -251,7 +251,7 @@ plugins: on_error: fail config: role: caller_workload - header: X-Workload-Token + credential: { kind: header, name: X-Workload-Token } trusted_issuers: - issuer: "https://spire-oidc.internal:8443" # SPIRE OIDC discovery audiences: ["https://idp.example.com/realms/corp"] # SVID aud = the IdP @@ -331,7 +331,7 @@ plugins: capabilities: [perform_http] config: role: client # the agent as an OAuth client - header: Authorization # a normal bearer token, NOT X-Workload-Token + credential: { kind: header, name: Authorization # a normal bearer token, NOT X-Workload-Token } trusted_issuers: - issuer: "https://idp.example.com/realms/corp" audiences: ["cpex"] diff --git a/docs/content/docs/migration-0.3.md b/docs/content/docs/migration-0.3.md index 3315642a..7467bf08 100644 --- a/docs/content/docs/migration-0.3.md +++ b/docs/content/docs/migration-0.3.md @@ -5,6 +5,8 @@ weight: 125 # Migrating from 0.2.x to 0.3 +For the current release, continue with [Migrating to 0.4]({{< relref "/docs/migration-0.4" >}}) after applying this historical 0.3 migration. + CPEX 0.3.0 runs on the praxis-policy engine (PPE) 0.5.0 from crates.io instead of its own engine crates. The API names, hook names, several config keys, defaults and security checks change. This page lists every change a 0.2.x user has to act on, by surface. Work through it in this order: diff --git a/docs/content/docs/migration-0.4.md b/docs/content/docs/migration-0.4.md new file mode 100644 index 00000000..bab77b5c --- /dev/null +++ b/docs/content/docs/migration-0.4.md @@ -0,0 +1,67 @@ +--- +title: "Migrating to 0.4" +weight: 124 +--- + +# Migrating to CPEX 0.4 + +CPEX 0.4.0 uses PPE 0.6.0 with the RustCrypto backend. It requires no FIPS provider or OpenSSL FIPS installation. The stable builtin set is unchanged; experimental rate limiting is not enabled. + +Upgrade the Rust/Python packages and Go module together. The native FFI artifact now uses **ABI 4** because the conversation-history wire contract changed. An older Go module rejects the new library at startup. + +## Configuration + +CIBA requires an explicit correlation store and rejects unknown config keys: + +```yaml +plugins: + - name: approver + kind: elicitation/ciba + hooks: [elicit] + capabilities: [perform_http] + config: + backchannel_endpoint: https://idp.example.com/ciba + token_endpoint: https://idp.example.com/token + client_id: gateway + client_secret_source: { kind: env_var, name: CIBA_CLIENT_SECRET } + store: { kind: memory } +``` + +Memory state is local to the plugin instance. Shared deployments can configure PPE's Valkey correlation store. Storage failures halt approval as `elicitation.store_unavailable` / JSON-RPC `-32122`; do not forward the operation. + +JWT credential locations now use `credential: { kind: header, name: Authorization }`. Legacy `header:` remains accepted. Bare-token Rust callers must provide a matching `source_header` for custom locations. Python and Go callers should supply the original header map; identity results omit those secret inputs. Resolver-owned fields remain invalid on binding input. + +Review monitoring that matches extraction codes: malformed JWT credentials now report `auth.malformed_credential`; missing, empty and ambiguous locations have distinct credential codes. DPoP is opt in per trusted issuer, needs `read_headers`, and returns verified `key_binding` on the resolved subject, client or workload. + +## Request state + +Pass each returned context table to the next hook **in the same request**. Start a new table for a new request. Go's `InvokeResolved` carries the table through identity and downstream policy, keeping DPoP proof reuse within one request distinct from replay on another request. + +`HttpExtension.query` is the raw query without `?`. Go uses `*string`: nil means no query and an empty string means a bare `?`. Supply `client_address` from the host's trusted peer context; PPE normalizes it. Do not copy these fields from untrusted forwarding headers. + +Results redact a present query to `[REDACTED]` and anonymize client addresses (IPv6 to a /64 prefix). Reconstruct live HTTP state from the original request before a separate hook call. Python and FFI reject that query marker as input. Preserve resolved identity and key bindings while restoring HTTP state. + +Conversation history is a list of CMF messages, oldest first. Go's `ConversationContext.History` is now `[]Message`; Python uses message dictionaries with `role` and `content`. Move free-form history strings to `summary`. + +## Support boundaries + +| Capability | Rust host | Python/Go bindings | Isolated Python plugins | +|---|---|---|---| +| Live HTTP query and normalized client address | supported | supported; restore live query between calls | canonical values preserved; query access rejected | +| `llm_request` | typed PPE carrier | non-null input rejected | access and replacement rejected | +| Acquired `metadata` | framework installation API | non-null input rejected | access and replacement rejected | +| `ratelimit.pre_identity`, `ratelimit.post_identity` | typed HTTP hooks; host dispatches stages | rejected; no HTTP payload carrier | unavailable | +| Header credential sources | supported | supported | supported | +| Cookie/query raw credential sources | supported | HTTP state supported; no dedicated raw carrier | rejected rather than converted to headers | + +An isolated plugin explicitly requesting `read_query`, `read_llm_request` or `read_metadata` fails before worker startup. `read_all` also fails if its effective view contains these unsupported slots. Use `for_child_request()` for a new Rust child invocation so acquired metadata does not inherit into it. + +Rust hosts enabling a limiter must dispatch pre-identity once before resolution, post-identity once after all resolvers, then the governed hook, including anonymous requests. Stop at a denial. CPEX's helpers do not enable or orchestrate a limiter. + +## Policy and auditing + +APL `&` and `|` can settle from a decisive operand even when the other operand cannot be ordered. Review policies relying on NaN or other unorderable values. + +Enabled hookless audit sinks can load beside routes under `dispatch: policy`. Reference audit loggers need an explicit `destination:`, and transcript recorders need `exporters:`. Register their Rust factories before loading; CPEX does not bundle them. See [Auditing]({{< relref "/docs/auditing" >}}). + +Keep the existing once-per-engine Rust builtin-install rule and binding guards. PPE #208 and #209 remain open: builtin installation and private-destination opt-in retain their existing behavior. diff --git a/docs/content/docs/reference.md b/docs/content/docs/reference.md index 284b5dab..e34a3a41 100644 --- a/docs/content/docs/reference.md +++ b/docs/content/docs/reference.md @@ -5,17 +5,17 @@ weight: 120 # Reference -CPEX 0.3 runs on the praxis-policy engine (PPE) 0.5.0. Rust hosts depend on the `cpex` facade, which re-exports PPE's public surface; plugin authors use `cpex::prelude`. The [Crate Map]({{< relref "/docs/crates" >}}) lists every crate and what it re-exports. +CPEX 0.4 runs on the praxis-policy engine (PPE) 0.6.0. Rust hosts depend on the `cpex` facade, which re-exports PPE's public surface; plugin authors use `cpex::prelude`. The [Crate Map]({{< relref "/docs/crates" >}}) lists every crate and what it re-exports. | Surface | Entry point | Where | |---|---|---| | Rust | `cpex::PolicyEngine` | [`crates/cpex`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex), [docs.rs/cpex](https://docs.rs/cpex) | | Python | `cpex.PolicyEngine` (`PluginManager` is an alias) | [`bindings/python`](https://github.com/contextforge-org/cpex/tree/main/bindings/python) | | Go | `cpex.PolicyEngine` | [`go/cpex`](https://github.com/contextforge-org/cpex/tree/main/go/cpex) | -| C ABI | `cpex_engine_*`, ABI version 3 | [`crates/cpex-ffi`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-ffi) | +| C ABI | `cpex_engine_*`, ABI version 4 | [`crates/cpex-ffi`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-ffi) | | Python plugins | `isolated_venv` kind (`python-host` feature) | [`crates/cpex-hosts-python`](https://github.com/contextforge-org/cpex/tree/main/crates/cpex-hosts-python) | -Engine API docs are on [docs.rs/praxis-policy/0.5.0](https://docs.rs/praxis-policy/0.5.0) and [docs.rs/praxis-policy-core/0.5.0](https://docs.rs/praxis-policy-core/0.5.0). +Engine API docs are on [docs.rs/praxis-policy/0.6.0](https://docs.rs/praxis-policy/0.6.0) and [docs.rs/praxis-policy-core/0.6.0](https://docs.rs/praxis-policy-core/0.6.0). ## Language bindings @@ -32,3 +32,5 @@ The C FFI is distributed as **signed prebuilt artifacts**. A host that links the ## Upgrading [Migrating to 0.3]({{< relref "/docs/migration-0.3" >}}) maps every 0.2.x API, hook name and config key to its 0.3.0 form. + +[Migrating to 0.4]({{< relref "/docs/migration-0.4" >}}) covers PPE 0.6.0, explicit CIBA stores, CMF history and ABI 4. diff --git a/docs/content/docs/testing.md b/docs/content/docs/testing.md index e328a7e7..85f8cbd6 100644 --- a/docs/content/docs/testing.md +++ b/docs/content/docs/testing.md @@ -68,7 +68,7 @@ Beyond unit tests, each tutorial module binary supports a `--check` flag that ru Unit-evaluating a route proves the policy logic. It does not prove the plugins it dispatches behave correctly end to end. For effects that call out (a PDP resolver, a delegator, a host plugin), add an integration test that exercises the real plugin through the engine, so the interaction is covered and not just the policy's intent. Test the failure paths too: a PDP that denies, a token exchange that returns a short scope, an IdP that is unreachable, a JWKS endpoint the transport refuses. -To test those failure paths without a server, a Rust test can install a scripted transport instead of a real one. `cpex::praxis_policy_core::http_testing::FakeTransport` (enable `test-util` on a `praxis-policy-core = "=0.5.0"` dev-dependency; PPE 0.5.0 does not forward this test feature through its facade) scripts responses, timeouts, connect failures and key rotation between two fetches, without sleeping. +To test those failure paths without a server, a Rust test can install a scripted transport instead of a real one. `cpex::praxis_policy_core::http_testing::FakeTransport` (enable `test-util` on a `praxis-policy-core = "=0.6.0"` dev-dependency; PPE 0.6.0 does not forward this test feature through its facade) scripts responses, timeouts, connect failures and key rotation between two fetches, without sleeping. A test that talks to a mock IdP on `localhost` needs a transport that allows private destinations (`HyperTransport::new().with_allow_private_destinations()` in Rust, `allow_private_destinations=True` in Python, `cpex.WithPrivateDestinations()` in Go). Keep that setting in test and demo hosts only. Those are the branches policy exists to handle. diff --git a/docs/content/docs/threat-model.md b/docs/content/docs/threat-model.md index 468878b9..bb28acc6 100644 --- a/docs/content/docs/threat-model.md +++ b/docs/content/docs/threat-model.md @@ -167,13 +167,13 @@ Extensions are a different matter: the engine trusts the `security.subject` a ho ### Elicitation approval binding -PPE 0.5.0 binds CIBA approvals to the authenticated requester, route and tool, and redeems them atomically once within the plugin instance. This fixes the cross-subject approval reuse present in CPEX 0.2.3. Requests without an authenticated subject id fail before the backchannel call. +PPE binds CIBA approvals to the authenticated requester, route and tool, and redeems them atomically once within the plugin instance. This fixes the cross-subject approval reuse present in CPEX 0.2.3. Requests without an authenticated subject id fail before the backchannel call. -The correlation store remains in process. A restart loses pending approvals, and replicas need sticky routing for a dispatch/resume pair. Keep approval timeouts short and return ids only to the requesting client. +Configure the correlation store explicitly. With `memory`, a restart loses pending approvals and replicas need sticky routing for a dispatch/resume pair. A shared `valkey` store supports cross-replica state; outages fail closed as `elicitation.store_unavailable`. Keep approval timeouts short and return ids only to the requesting client. -### Audit sinks cannot run beside routes +### Decision records require host configuration -Under `dispatch: policy`, PPE 0.5.0 cannot attach an audit sink to a config that has routes: a sink no step names fails the load, and `run()` denies. The "unaccountable actions" control above therefore needs a hook plugin that a `run(...)` step names, or a host that logs the `PipelineResult` of every call. A host that does neither has no decision record. See [Auditing]({{< relref "/docs/auditing" >}}). +PPE 0.6.0 supports enabled hookless audit sinks beside routes under `dispatch: policy`. CPEX ships no sink: a Rust host must register one, a Go host can link a Rust factory, and every host can log each `PipelineResult`. Without either, there is no decision record. See [Auditing]({{< relref "/docs/auditing" >}}). ### Host-supplied client identity is visible only through `read_client` diff --git a/docs/content/docs/tutorial/02-identity.md b/docs/content/docs/tutorial/02-identity.md index 1f5e5f2d..1100b98b 100644 --- a/docs/content/docs/tutorial/02-identity.md +++ b/docs/content/docs/tutorial/02-identity.md @@ -28,7 +28,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/tutorial/11-groups.md b/docs/content/docs/tutorial/11-groups.md index 0d84f738..7428baef 100644 --- a/docs/content/docs/tutorial/11-groups.md +++ b/docs/content/docs/tutorial/11-groups.md @@ -27,7 +27,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/tutorial/13-client.md b/docs/content/docs/tutorial/13-client.md index ae030f83..20f83526 100644 --- a/docs/content/docs/tutorial/13-client.md +++ b/docs/content/docs/tutorial/13-client.md @@ -41,7 +41,7 @@ plugins: capabilities: [perform_http] config: role: client - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/tutorial/14-passthrough.md b/docs/content/docs/tutorial/14-passthrough.md index 1952ae06..34d34125 100644 --- a/docs/content/docs/tutorial/14-passthrough.md +++ b/docs/content/docs/tutorial/14-passthrough.md @@ -31,7 +31,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/tutorial/15-dual-principal.md b/docs/content/docs/tutorial/15-dual-principal.md index cd9be1e7..1709aa21 100644 --- a/docs/content/docs/tutorial/15-dual-principal.md +++ b/docs/content/docs/tutorial/15-dual-principal.md @@ -33,7 +33,7 @@ plugins: capabilities: [perform_http] config: role: user - header: X-User-Token + credential: { kind: header, name: X-User-Token } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial @@ -52,7 +52,7 @@ plugins: capabilities: [perform_http] config: role: client - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/tutorial/16-workload.md b/docs/content/docs/tutorial/16-workload.md index 66e182cf..1566bc20 100644 --- a/docs/content/docs/tutorial/16-workload.md +++ b/docs/content/docs/tutorial/16-workload.md @@ -55,7 +55,7 @@ plugins: capabilities: [perform_http] config: role: caller_workload - header: X-Workload-Token + credential: { kind: header, name: X-Workload-Token } claim_mapper: standard trusted_issuers: - issuer: http://spire-oidc:8443 diff --git a/docs/content/docs/tutorial/17-federation.md b/docs/content/docs/tutorial/17-federation.md index 6159adbe..f24a5974 100644 --- a/docs/content/docs/tutorial/17-federation.md +++ b/docs/content/docs/tutorial/17-federation.md @@ -27,7 +27,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: # Home issuer: your own workforce realm. diff --git a/docs/content/docs/tutorial/18-attributes.md b/docs/content/docs/tutorial/18-attributes.md index 50b177d3..7ff41254 100644 --- a/docs/content/docs/tutorial/18-attributes.md +++ b/docs/content/docs/tutorial/18-attributes.md @@ -49,7 +49,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/docs/content/docs/use-cases.md b/docs/content/docs/use-cases.md index 6b07fe32..44a07cdf 100644 --- a/docs/content/docs/use-cases.md +++ b/docs/content/docs/use-cases.md @@ -15,7 +15,7 @@ The demo realizes the [running scenario]({{< relref "/docs/overview" >}}): one a | Eve | HR, no `view_ssn` | Same record, SSN redacted on the wire | | Alice | Engineering | Denied HR tools; allowed internal repos, denied external | -[Praxis](https://github.com/praxis-proxy/praxis) is an AI-native proxy built around a filter chain. Its `policy` filter (the `policy-engine` feature) runs the praxis-policy engine (PPE), the same engine CPEX 0.3 embeds, so the gateway parses MCP JSON-RPC, runs the full policy pass, and only then forwards a scoped request upstream. The policy document is the same format a CPEX host loads: +[Praxis](https://github.com/praxis-proxy/praxis) is an AI-native proxy built around a filter chain. Its `policy` filter (the `policy-engine` feature) runs the praxis-policy engine (PPE), the same engine CPEX 0.4 embeds, so the gateway parses MCP JSON-RPC, runs the full policy pass, and only then forwards a scoped request upstream. The policy document is the same format a CPEX host loads: ![The Praxis demo topology: a chat agent calls the Praxis gateway over MCP, where the mcp, policy (CPEX), and router filters run in sequence before forwarding to the hr-mcp server; the policy filter is configured by policy.yaml and talks to Keycloak for identity, token exchange, and CIBA, and to Valkey for session taint, while Keycloak pushes CIBA approvals to the auth-channel UI](images/use_cases_topology.png) diff --git a/docs/plans/2026-11-09-001-ppe-0.6.0-upgrade-plan.md b/docs/plans/2026-11-09-001-ppe-0.6.0-upgrade-plan.md new file mode 100644 index 00000000..abbad6bf --- /dev/null +++ b/docs/plans/2026-11-09-001-ppe-0.6.0-upgrade-plan.md @@ -0,0 +1,164 @@ +--- +title: "Upgrade CPEX to PPE 0.6.0" +type: refactor +status: implemented +date: 2026-11-09 +--- + +# Upgrade CPEX to PPE 0.6.0 + +## Scope and evidence + +Implement on the existing `feat/ppe_0.6.0` branch. At planning time, CPEX had workspace version `0.3.1` and pinned the crates.io `praxis-policy` facade, aliased as `ppe`, to `=0.5.0` with defaults disabled. The implementation and validation results are recorded below. + +Reviewed the [PPE 0.6.0 release](https://github.com/praxis-proxy/policy/releases/tag/v0.6.0), published November 6, its [changelog](https://github.com/praxis-proxy/policy/blob/v0.6.0/CHANGELOG.md), and the [0.5.0–0.6.0 source comparison](https://github.com/praxis-proxy/policy/compare/v0.5.0...v0.6.0). The local upstream checkout is clean and matches the GitHub release tag: + +- PPE 0.5.0: `d29a6a75f37a37fc9815d184ccf21564b2f4ac00`. +- PPE 0.6.0: `6b110aa9c400337b309426653886acf961f8f958`. + +The recommendation is a stable RustCrypto upgrade with the existing builtin set. Keep FIPS and experimental Limitador enablement as separate work. New upstream capabilities must either have an explicit binding implementation or report that they are unsupported; generic serde is insufficient for several of them. + +## Change assessment + +| PPE change | CPEX exposure and action | Priority | +|---|---|---| +| Exactly one crypto backend is required when defaults are disabled | Add `features = ["crypto-rustcrypto"]` to the workspace PPE dependency while moving to `=0.6.0`. This also supplies a backend when the independently published Python host or Biscuit plugin builds without the CPEX facade. | Required build change | +| `PluginError::Crypto`; fallible hashing and startup validation | Add the missing Python exception arm in `bindings/python/src/error.rs`; review initialization rollback and the C/Go error paths. CPEX has no direct production calls to the changed PPE digest helpers in the inspected tree. Its Python-venv and parity-key hashes are separate implementations. | Required build/error change | +| `IdentityPayload.headers` becomes `#[serde(skip)]`, affecting both serialization and deserialization | Replace direct PPE identity deserialization at Python and FFI entry points with a CPEX input decoder that reconstructs private inputs using PPE constructors/builders. Otherwise existing header-authentication calls lose their credential while still parsing successfully. | Required runtime change | +| `RawInboundToken.source_header` becomes typed `source: Credential` | Adapt `crates/cpex-hosts-python/src/credentials.rs`, its fixtures, and parity credential projection. Handle header, cookie and query sources explicitly; a location name is not always a header name. | Required build/wire change | +| JWT `credential:` locations; changed extraction denials | Migrate maintained JWT YAML from `header:` to `credential: { kind: header, name: ... }`; retain tests proving legacy `header:` works. Review malformed/missing/empty/ambiguous denial codes, including Python acceptance's `auth.malformed` expectation. | Required compatibility review | +| Raw HTTP query, `ReadQuery`, and trusted normalized `client_address` | Update exhaustive `HttpExtension` literals in the Python host and add corresponding Go input fields. Preserve canonical fields on worker returns. Query serialization emits `[REDACTED]`, so reusing returned serialized extensions as the next hook's live input changes policy input. | Required bridge change | +| Rate-limit scopes, two HTTP-family hooks, private context-table field | Review invocation choreography and thread one `PluginContextTable` through each request. Update the parity hook inventory. Enable no experimental limiter by default. | Required host-contract/inventory review | +| CIBA requires explicit `store`, rejects unknown config keys, and has async/fallible storage | Add `store: { kind: memory }` to CPEX's maintained CIBA configs and generated test YAML. CPEX does not implement PPE's `CorrelationStore` directly. Add binding checks for store failures and `elicitation.store_unavailable` / JSON-RPC `-32122`. | Required config change | +| DPoP and `key_binding` on subject, client and workload identities | Add Go key-binding types/fields; preserve resolver-produced bindings through downstream extensions. Maintain request context for repeated proof verification. A bare token now falls back only for a matching source header, or implicit Authorization. | Required type/host review; DPoP deployments opt in | +| `ConversationContext.history: Vec` | Change/document Go's current `[]any` history contract to CMF messages; validate Python dict history and review legacy-worker translation. Empty history remains valid; free-form summaries must use `summary`. | Required public contract change | +| Nonserialized `Extensions.llm_request`; canonical `llm.*` attributes | Rust gains the carrier through the facade. Python and Go generic extension decoders cannot populate it. The isolated Python host's explicit slot list also cannot transmit it. Specify an explicit input/worker DTO or reject unsupported use; retain existing attribute aliases. | Explicit support boundary required | +| Nonserialized immutable `Extensions.metadata`, `ReadMetadata`, `SlotName::Metadata` | Preserve canonical metadata during worker merges and child invocation boundaries. It is acquired framework state, not a field to accept from arbitrary generic JSON. The current worker slot list and capability vocabulary do not implement it. | Explicit support boundary required | +| Hookless enabled audit sinks can load with `dispatch: policy`; reference sinks require output destinations | Correct CPEX's audit docs, which currently say sinks cannot load beside routes. Reference audit/transcript plugins remain outside the shipped builtin set. Document explicit `destination:` / `exporters:` for hosts copying reference plugins. | Required documentation correction | +| APL `&` / `|` can settle despite another unorderable operand; rustls security update | Review changed decisions with focused expressions and parity. Review the refreshed dependency graph and `cargo deny` results. Do not assume old denial counts remain fixed. | Required behavior/dependency review | + +PPE 0.6.0 declares MSRV 1.92; CPEX already requires 1.96. The maintained YAML fork remains `yaml_serde` 0.10.4. Neither requires a CPEX toolchain or YAML-library migration. + +## Implementation decisions + +### Identity input must have its own decoder + +Add a shared CPEX host helper, used by `bindings/python/src/conversions.rs` and both identity decode paths in `crates/cpex-ffi/src/lib.rs`: `deserialize_payload(PAYLOAD_IDENTITY, ...)` and `cpex_invoke_resolved`. + +Preserve the current caller shape (`source`, `source_header`, `headers`, `client_host`, `client_port`). Extract and validate headers separately, then populate PPE through `IdentityPayload::new` and `with_headers` / source/client builders. Do not restore serialization of PPE's secret inputs. Keep identity results sanitized by the existing `identity_result_value` helper. + +Reject nonempty resolver-owned input fields before constructing the payload: subject, client, caller workload, delegation, raw credentials, resolution timestamp and raw claims. A DTO that simply ignores these would weaken the existing input guard. Keep malformed-input diagnostics free of header/token values. + +Regression coverage must exercise real JWT identity through both Python and Go, including `InvokeResolved`, custom and multiple credential headers, missing credentials, forged resolver outputs, and result redaction. The Rust-only parity loop constructs identity payloads directly, so parity alone cannot detect this binding regression. + +### Credential sources and live HTTP state need explicit handling + +Map `Credential::Header` to the existing legacy-worker `source_header` contract. For cookie/query credentials, either introduce a worker DTO and negotiated feature that represents the location faithfully, or refuse the unsupported source with a clear host error. Never synthesize a header from a cookie/query parameter name. Match upstream non-exhaustive enums with a rejecting fallback. + +In `crates/cpex-hosts-python/src/extensions.rs`, adapt `sanitize_http` and `returned_http` for `query` and `client_address`. Worker header edits may not overwrite the canonical query, client address, request line or secret-header provenance. A plugin without `read_query` must receive no live query. If worker query access is implemented, construct the granted value explicitly instead of relying on `HttpExtension::Serialize`, and negotiate worker support first. + +Go should distinguish an absent query from an empty query, for example `Query *string`, because PPE distinguishes no query from a trailing `?`. `ClientAddress` should be host-supplied; PPE normalizes it on decode. Document that host callers must supply canonical HTTP fields from their trusted request context. + +For separate Python/Go hook calls, reconstruct query from the original request or retain a dedicated live request context. Serialized `ModifiedExtensions` is a diagnostic/result representation for this field. Do not allow a `[REDACTED]` query returned by PPE to replace the original policy input. Preserve key bindings while reconstructing the remaining extension state. + +### Request context must survive identity and downstream hooks + +`cpex_invoke_resolved` currently invokes identity with `None` context and then passes the caller's original context to the downstream hook, discarding identity's table. Change it to pass the incoming table to identity and continue with identity's returned table. This is needed for PPE's within-request DPoP proof reuse and rate-limit stage marker. + +Apply the same context threading to `examples/tutorial/src/mediate.rs` and `tests/parity/harness/src/engine.rs`, which currently pass `None` at each stage. Separate public calls should document that the returned context table belongs to the same request and must be passed onward; a new request gets a new table. + +For a host offering rate limiting, the sequence is `ratelimit.pre_identity` before the first identity resolution, then `ratelimit.post_identity` after the last, then the governed invocation. Run each applicable stage once, including anonymous requests, and stop on a denial. Use PPE's HTTP payload/hook family. The upgrade's default feature set includes no limiter, so context plumbing does not require enabling Limitador. Low-level dispatch remains host-controlled; document its ordering obligation and make any automatic helper orchestration avoid double dispatch. + +The Python decoder currently maps every non-CMF/non-identity hook to `GenericPayload`, and the FFI payload registry has no HTTP payload type. Exposing the rate-limit hooks to these callers therefore also requires typed HTTP payload construction or a dedicated orchestration helper; advertising the hook names alone does not provide working binding support. Until implemented, document the Rust-host support boundary. + +Update `tests/parity/hook-renames.yaml`: `ratelimit.pre_identity` and `ratelimit.post_identity` must be represented in the hook inventory (explicitly unused in the existing legacy capture set if no fixture exercises them). Its current `unused: []` makes the registry-completeness check fail under 0.6.0. + +### New nonserialized slots require a declared support boundary + +Keep `llm_request` and acquired `metadata` intact in canonical Rust state and through `cow_copy`-based immutable merges. Reject worker attempts to replace them. Use `for_child_request()` when beginning a child policy invocation so acquired metadata does not inherit into a new invocation. + +The current legacy SDK worker only advertises `credential`, `extensions`, and `cmf_hooks`; the host's `WORKER_KNOWN_CAPABILITIES` lacks `read_query`, `read_llm_request`, and `read_metadata`. For this upgrade, fail startup for a plugin explicitly requiring a new capability the installed worker cannot honor. Check the effective `read_all` view too: it must not become a route for accidental secret exposure. Supporting these fields fully requires feature negotiation and SDK changes outside this branch, followed by capability-filtered DTOs and immutable return handling. + +For Python/Go callers, do not silently accept `llm_request` or `metadata` in the generic extensions map when PPE will ignore them. An LLM body/parts input can later be built explicitly into `LlmRequest` at the trusted host boundary. Metadata requires the acquired-facts installation API and provenance rules, not a plain deserialized map. Document the Rust support and binding limitations until those APIs exist. + +### Config migration and release compatibility + +Add `store: { kind: memory }` to `tests/parity/scenarios/elicitation-ciba/config.ppe.yaml` and `bindings/python/tests/test_builtins.py`'s generated CIBA config. Search maintained docs and all generated test/config strings for additional CIBA entries. Do not change the legacy `config.yaml` or retained baseline sources. + +Move maintained JWT `header:` settings in tutorial policies, current parity configs and Go test YAML to `credential:`. Keep a deliberate legacy-header compatibility case. Check callers using bare tokens for non-Authorization resolvers: supply a matching `source_header` or an actual request header map instead of relying on the old broader fallback. + +Recommend releasing as CPEX **0.4.0** because explicit CIBA storage and typed Go history alter user-facing contracts. Choose and apply the release version in release preparation, separately from the dependency investigation. A C ABI function signature need not change for the input-decoder fix. Review and test the MessagePack contract; if any existing payload contract becomes incompatible, bump `FFI_ABI_VERSION` and Go's expected version together so mixed artifacts fail clearly. + +Keep PPE [#208](https://github.com/praxis-proxy/policy/issues/208) and [#209](https://github.com/praxis-proxy/policy/issues/209) as follow-ups. Both were still open on November 9. Retain binding builtin-install guards and the existing private-destination opt-in behavior and warnings. PPE 0.6.0 does not supply either requested fix. + +## Work order + +1. **Dependency and compile adaptation.** Change the exact pin and select RustCrypto in `Cargo.toml`; refresh `Cargo.lock`; update the expected PPE pin and status text in `scripts/release/check-release.py`. Fix the Python crypto error arm, typed raw-token sources, HTTP literals and affected fixtures. Verify independently published crates do not depend on feature unification from `cpex` to get a crypto backend. +2. **Authentication and wire compatibility.** Add the shared identity input decoder, wire both FFI paths and Python to it, update Go identity documentation, and exercise authentication through the actual bindings. Preserve input guards and sanitized results. +3. **Canonical request state.** Thread context tables through identity/pre/post hooks, preserve HTTP query/client address and DPoP bindings, migrate Go history to CMF messages, and implement clear unsupported-capability/input errors for the new nonserialized slots. Review worker merges and handshake requirements. +4. **Configs and parity.** Add explicit CIBA memory stores, migrate maintained JWT locations, update the hook inventory and parity credential-source projection. Replay the complete existing request set with a disposable Valkey database. Review each new or stale intended difference before editing the allow-list. +5. **Documentation.** Add a PPE 0.6.0 upgrade section/guide and an unreleased changelog entry. Update current crate/dependency examples, CIBA/JWT/history/query host contracts, and audit-sink guidance. Keep historical release notes and the 0.5.0 test reconciliation factual; append new validation results rather than rewriting history. Reference-plugin copy instructions need the 0.6.0 source and explicit backend selection. Update binding migration docs, stubs and Go comments with the implemented support boundary. +6. **Release validation.** Run the gates below, package the four published crates and static FFI artifacts, and record actual results, ignored/skipped tests and reviewed behavior differences. Apply the selected CPEX release version consistently at this stage. Commit/tag/publish are separate release actions. + +## Validation and acceptance + +Run these during implementation; they were not run against PPE 0.6.0 while preparing this plan. + +| Gate | Command / acceptance | +|---|---| +| Compile all supported surfaces | `cargo check --workspace --all-targets --locked`; separately check the facade with `--features python-host` and build examples. Verify `cargo tree -e features` has RustCrypto and no FIPS backend. | +| Rust behavior and optional Python host | `make lint`; `cargo test --workspace --locked -- --test-threads=4`; `cargo test -p cpex --features python-host --locked`. Four threads match the prior Python-subprocess test environment. | +| Existing baseline parity | `make parity PARITY_VALKEY_URL=`. All 68 legacy requests run, including Valkey read-back and determinism. Original 0.2.3 snapshots, legacy configs and baseline lockfile remain unchanged. The harness flushes this database. | +| Python binding behavior | `make bindings-python-test`, including real identity, typed history, CIBA and unsupported-input cases; `make bindings-python-smoke` to verify the installed wheel outside the source tree. | +| Legacy Python plugin host | `make test-python-e2e CPEX_PYTHON_SOURCE=`. Existing credential, extension merge and acceptance suites actually execute without skips. Record the SDK commit used. | +| Go binding behavior and native link dependencies | `make go-test-race GO_STATIC=1`; verify header identity, `InvokeResolved` context, query and key-binding round trips, and typed CMF history. | +| Tutorial/runtime configs | `make tutorial-check-local`; `make tutorial-check` for the IdP-backed identity/delegation/CIBA paths; `make examples-build`. Record the separate SPIRE lane if it is run. | +| Docs and dependency audit | `make doc`, `make docs`, existing documentation link checks, and `make audit`. Review new dependencies/licenses and resolved rustls advisory status. | +| Publish/FFI contract | `make release-check`, `make publish-dry`, `make ffi-verify`, and release target CI. Published host/plugin crates build independently, the Rust/Go ABI versions agree, and the packaged static archive passes the Go race tests. | + +Add focused CPEX boundary tests where the upgrade changes what hosts supply or observe: identity headers retained on input but absent from results; custom/multiple headers; typed credential source handling; canonical query/client address preserved despite worker edits; DPoP context reused within a request but not across requests; history accepts CMF messages and rejects free-form entries; CIBA missing/invalid stores fail at load and unavailable stores halt with `-32122`; unavailable worker capabilities fail clearly. An APL regression should cover settled `&` / `|` results in both operand orders without copying PPE's entire engine suite. + +For the parity projector, choose a documented normalization for legacy header credentials or record the typed-source shape as a reviewed wire difference. Either approach must preserve the source information and redact token values. Review changed credential error codes independently from changed authorization decisions. Never allow-list a general authentication failure caused by a lost header. + +Completion means every maintained config loads under the chosen profile, authenticated binding calls retain their behavior except for documented PPE changes, all release-required lanes execute, and docs accurately distinguish implemented binding features from upstream Rust capabilities. Report the observed test totals and remaining external SDK limitations with the final upgrade results. + +## Implementation results + +Implemented on `feat/ppe_0.6.0` in `24e1910` as CPEX 0.4.0, FFI ABI 4. The user selected RustCrypto and explicitly excluded FIPS support from this task. The workspace dependency supplies `crypto-rustcrypto` to every consumer; the feature graph contains no FIPS backend. No limiter was enabled. + +Binding identity decoders restore private headers through PPE builders while retaining resolver-output guards and sanitized results. Fused FFI calls, the tutorial and parity thread request context. Go carries key bindings, raw query/client address and typed CMF history. Unsupported nonserialized inputs and worker capabilities fail clearly. Maintained JWT/CIBA configs and the hook inventory are migrated. Historical configs, snapshots and the baseline lockfile are unchanged. + +The only added parity differences are the two malformed JWT credential code fields (`auth.malformed_header` → `auth.malformed_credential`), citing PPE #96. All 68 legacy requests, Valkey read-back and determinism ran successfully. + +| Validation | Observed result | +|---|---| +| Workspace tests with `--locked -- --test-threads=4` | 420 passed, 17 ignored; no failures. Ignored worker suites are covered separately below. | +| Facade with `python-host` | 19 passed, 1 ignored documentation example. | +| `make lint` | Passed for workspace and optional Python-host feature. | +| Python binding tests after final locked rebuild | 77 passed. Real header identity, typed history, CIBA store failures and unsupported-input tests executed. | +| Python worker e2e | 14 passed, zero ignored/skipped required bodies, using SDK `383c51c36ed31da1236767b41e3f9088966a7b1d` from `cpex-0.1.x`. | +| Parity with disposable Valkey | 12 harness tests passed; all 68 replay requests and persistent label read-back executed. | +| `make go-test-race GO_STATIC=1` | Passed, including actual FFI DPoP proof reuse/replay, query identity, query redaction, address anonymization and CMF history. | +| Tutorial | All local and base IdP-backed modules passed. Separate SPIRE overlay was not run. | +| Examples | Rust and Go examples built with the static archive. | +| Rustdoc and Hugo | Passed. Internal link check: 8,623 OK, zero errors, 177 external links excluded in offline mode. PPE source links checked against both release-tag trees. | +| `make release-check` | Four published crates, version 0.4.0, PPE 0.6.0 and ABI 4 agree. | +| `make publish-dry` | All four crates packaged and independently verified; nothing uploaded. | +| Python wheel | Release wheel installed into a fresh venv and policy smoke check passed outside the source tree. | +| Packaged FFI | `make ffi-verify VERSION=0.4.0` passed for darwin-arm64: archive checksum, metadata, ABI 4, Go vet and race tests against the extracted static library. Local bundle is unsigned. | +| Feature graph | RustCrypto selected throughout; no FIPS backend. | +| Dependency audit | Licenses, bans and sources pass. Advisories remain blocked by the existing Biscuit dependency described below. | + +### Remaining release blocker + +Three compatible security fixes are locked: `ctutils` 0.4.4, `rustls-webpki` 0.103.16 and `webpki-roots` 1.0.10. `make audit` still reports RUSTSEC-2026-0387 for `crypto-bigint` 0.5.5 through `biscuit-auth` 6.0.0 → `elliptic-curve` 0.13.8. The advisory's fixed version is 0.7.6, outside the published dependency requirement. No advisory suppression or incompatible override was added. Resolve this separately before release publication. + +Native linking emitted macOS deployment-target warnings (15.2 objects linked for 15.0); verification passed. Release target CI was not triggered locally. No tags, push or publication were performed. The disposable Valkey and tutorial Keycloak containers were removed after validation. + +### Biscuit follow-up and FIPS audit + +On November 9, the user chose to retain crates.io-only dependencies and defer the Biscuit fix until upstream publishes it. The latest published `biscuit-auth` remains 6.0.0. Upstream [#349](https://github.com/eclipse-biscuit/biscuit-rust/pull/349), merged at `93832a82bf509ad81f05fdb6adaf69d03c56887b`, upgrades the RustCrypto stack but includes breaking crypto and RNG APIs. [#351](https://github.com/eclipse-biscuit/biscuit-rust/issues/351) tracks the release/backport request. No Git dependency, vendored crypto patch, downgrade or advisory waiver was added. + +When a fixed release is available, update the dependency and lockfile, adapt the key API, verify interoperability with released 6.0.0 tokens, and run the Biscuit integration tests, dependency audit and package verification. RUSTSEC-2026-0387 remains a release blocker meanwhile. + +The tracked source and current workspace feature graph contain no FIPS implementation, feature, configuration or runtime dependency. PPE selects only `crypto-rustcrypto`; neither OpenSSL libraries nor `aws-lc-fips-sys` are resolved. Remaining FIPS mentions document this support boundary. The `openssl rand` audit-doc example is a random-byte generation utility, not a FIPS dependency. The backend-neutral `PluginError::Crypto` handling remains required by PPE 0.6.0. diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod index 4a0d1f00..3600aef9 100644 --- a/examples/go-demo/go.mod +++ b/examples/go-demo/go.mod @@ -2,7 +2,7 @@ module github.com/contextforge-org/cpex/examples/go-demo go 1.25.4 -require github.com/contextforge-org/cpex/go/cpex v0.3.1 +require github.com/contextforge-org/cpex/go/cpex v0.4.0 require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect diff --git a/examples/tutorial/policies/m02.yaml b/examples/tutorial/policies/m02.yaml index cecda728..7cbae498 100644 --- a/examples/tutorial/policies/m02.yaml +++ b/examples/tutorial/policies/m02.yaml @@ -25,7 +25,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/policies/m11.yaml b/examples/tutorial/policies/m11.yaml index 67fd0808..11497c8a 100644 --- a/examples/tutorial/policies/m11.yaml +++ b/examples/tutorial/policies/m11.yaml @@ -22,7 +22,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/policies/m13.yaml b/examples/tutorial/policies/m13.yaml index 3caf8a6e..426ddbee 100644 --- a/examples/tutorial/policies/m13.yaml +++ b/examples/tutorial/policies/m13.yaml @@ -29,7 +29,7 @@ plugins: capabilities: [perform_http] config: role: client - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/policies/m14.yaml b/examples/tutorial/policies/m14.yaml index 93c49454..786d7931 100644 --- a/examples/tutorial/policies/m14.yaml +++ b/examples/tutorial/policies/m14.yaml @@ -22,7 +22,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/policies/m15.yaml b/examples/tutorial/policies/m15.yaml index 4582f072..8156f18a 100644 --- a/examples/tutorial/policies/m15.yaml +++ b/examples/tutorial/policies/m15.yaml @@ -27,7 +27,7 @@ plugins: capabilities: [perform_http] config: role: user - header: X-User-Token + credential: { kind: header, name: X-User-Token } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial @@ -46,7 +46,7 @@ plugins: capabilities: [perform_http] config: role: client - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/policies/m16.yaml b/examples/tutorial/policies/m16.yaml index d5daa10c..9bb9f8f6 100644 --- a/examples/tutorial/policies/m16.yaml +++ b/examples/tutorial/policies/m16.yaml @@ -31,7 +31,7 @@ plugins: capabilities: [perform_http] config: role: caller_workload - header: X-Workload-Token + credential: { kind: header, name: X-Workload-Token } claim_mapper: standard trusted_issuers: - issuer: http://spire-oidc:8443 diff --git a/examples/tutorial/policies/m17.yaml b/examples/tutorial/policies/m17.yaml index 679c3999..0ece0fbf 100644 --- a/examples/tutorial/policies/m17.yaml +++ b/examples/tutorial/policies/m17.yaml @@ -18,7 +18,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: # Home issuer: your own workforce realm. diff --git a/examples/tutorial/policies/m18.yaml b/examples/tutorial/policies/m18.yaml index eed6d208..767cbbb9 100644 --- a/examples/tutorial/policies/m18.yaml +++ b/examples/tutorial/policies/m18.yaml @@ -25,7 +25,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: http://localhost:8081/realms/cpex-tutorial diff --git a/examples/tutorial/src/mediate.rs b/examples/tutorial/src/mediate.rs index cdf7e15d..1791bd3f 100644 --- a/examples/tutorial/src/mediate.rs +++ b/examples/tutorial/src/mediate.rs @@ -162,6 +162,7 @@ where { // --- Base extensions: which entity is this, and (optionally) which // session does it belong to. Meta drives APL route matching. --- + let mut context_table = None; let mut ext = Extensions { meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), @@ -208,12 +209,18 @@ where }, }; let (id_result, id_bg) = engine - .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, ext.clone(), None) + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + payload, + ext.clone(), + context_table.take(), + ) .await; id_bg.wait_for_background_tasks().await; if !id_result.continue_processing { return denied(&id_result); } + context_table = Some(id_result.context_table.clone()); if let Some(identity) = IdentityPayload::from_pipeline_result(&id_result) { ext = identity.apply_to_extensions(ext); } @@ -224,7 +231,12 @@ where message: tool_call_message(tool, &args), }; let (pre_result, pre_bg) = engine - .invoke_named::("cmf.tool_pre_invoke", pre_payload, ext.clone(), None) + .invoke_named::( + "cmf.tool_pre_invoke", + pre_payload, + ext.clone(), + context_table.take(), + ) .await; pre_bg.wait_for_background_tasks().await; if !pre_result.continue_processing { @@ -267,7 +279,12 @@ where message: tool_result_message(tool, &raw_result), }; let (post_result, post_bg) = engine - .invoke_named::("cmf.tool_post_invoke", post_payload, post_ext, None) + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table.clone()), + ) .await; post_bg.wait_for_background_tasks().await; if !post_result.continue_processing { diff --git a/go/cpex/README.md b/go/cpex/README.md index 88d56635..532e0b6d 100644 --- a/go/cpex/README.md +++ b/go/cpex/README.md @@ -124,7 +124,7 @@ A value that fails to re-read keeps its last-good bytes and is listed in `report ## ABI Check -On package init the binding compares its expected FFI ABI version (3) with the one `libcpex_ffi` reports and panics on a mismatch, naming both versions. Rebuild `libcpex_ffi` from the same CPEX release as the Go module. +On package init the binding compares its expected FFI ABI version (4) with the one `libcpex_ffi` reports and panics on a mismatch, naming both versions. Rebuild `libcpex_ffi` from the same CPEX release as the Go module. ## Payload Types @@ -182,7 +182,7 @@ There is no Go field for the Rust `raw_credentials` slot. Its tokens are never s ## Context Threading -Pass the `ContextTable` from one invocation to the next to preserve per-plugin state across hooks: +Pass the `ContextTable` from one invocation to the next to preserve per-plugin state across hooks in one request. Start a new request with nil: ```go result1, ct1, bg1, _ := engine.InvokeByName("cmf.tool_pre_invoke", ...) @@ -221,7 +221,7 @@ edition = "2021" crate-type = ["staticlib", "cdylib"] [dependencies] -ppe = { package = "praxis-policy", version = "=0.5.0", default-features = false } +ppe = { package = "praxis-policy", version = "=0.6.0", default-features = false, features = ["crypto-rustcrypto"] } cpex-ffi = { path = "path/to/crates/cpex-ffi" } async-trait = "0.1" tracing = "0.1" @@ -436,3 +436,9 @@ cd go/cpex && go test -v ./... - [Rust crates README](../../crates/README.md): the facade and the crates CPEX adds to the engine - [Migrating to 0.3](https://contextforge-org.github.io/cpex/docs/migration-0.3/): the Go, C ABI, hook and config changes from 0.2.x - [Tutorial](../../examples/tutorial/README.md): policy examples, builtins and custom plugins + +## CPEX 0.4 request state + +This release uses PPE 0.6.0 with RustCrypto and ABI 4. `InvokeResolved` threads context through identity and policy. Use `ConversationContext.History []Message` for earlier CMF turns and `Summary` for free-form summaries. Verified identities can carry `KeyBinding`. + +`HttpExtension.Query` excludes `?`; nil means absent and a pointer to an empty string means a bare `?`. Results redact it to `[REDACTED]`, which FFI refuses as live input. Restore it from the original request between separate hook calls. Supply `ClientAddress` from trusted peer context. Query/cookie JWT locations can use HTTP extension state; no dedicated raw credential carrier is exposed. The two `ratelimit.*` hooks, `llm_request` and acquired `metadata` require Rust host APIs. See the [0.4 migration guide](../../docs/content/docs/migration-0.4.md). diff --git a/go/cpex/abi.go b/go/cpex/abi.go index bee51ec0..588b480f 100644 --- a/go/cpex/abi.go +++ b/go/cpex/abi.go @@ -32,7 +32,7 @@ import "fmt" // expectedFFIABIVersion is the FFI_ABI_VERSION integer this binding // was generated against. Bump in lockstep with the Rust crate's // FFI_ABI_VERSION whenever the C surface changes in a breaking way. -const expectedFFIABIVersion uint32 = 3 +const expectedFFIABIVersion uint32 = 4 // checkFFIABIVersion returns an error when the ABI version reported by // the linked libcpex_ffi differs from the one this binding expects. diff --git a/go/cpex/abi_test.go b/go/cpex/abi_test.go index 84261496..3bcc9001 100644 --- a/go/cpex/abi_test.go +++ b/go/cpex/abi_test.go @@ -36,11 +36,11 @@ func TestCheckFFIABIVersionRejectsMismatch(t *testing.T) { } } -func TestLinkedLibraryReportsABI3(t *testing.T) { - if got := linkedFFIABIVersion(); got != 3 { - t.Errorf("expected linked libcpex_ffi ABI 3, got %d", got) +func TestLinkedLibraryReportsABI4(t *testing.T) { + if got := linkedFFIABIVersion(); got != 4 { + t.Errorf("expected linked libcpex_ffi ABI 4, got %d", got) } - if expectedFFIABIVersion != 3 { - t.Errorf("expected binding ABI 3, got %d", expectedFFIABIVersion) + if expectedFFIABIVersion != 4 { + t.Errorf("expected binding ABI 4, got %d", expectedFFIABIVersion) } } diff --git a/go/cpex/acceptance_test.go b/go/cpex/acceptance_test.go index a7db5d73..cd53715e 100644 --- a/go/cpex/acceptance_test.go +++ b/go/cpex/acceptance_test.go @@ -23,7 +23,9 @@ package cpex import ( + "crypto/ed25519" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" @@ -549,3 +551,139 @@ global: wantDenied(t, "second engine after taint", callTool(t, second, alice, "send_email", email, session), "session_tainted") wantAllowed(t, "fresh session on second engine", callTool(t, second, alice, "send_email", email, session+"-fresh")) } + +func TestAcceptanceDPoPContextSurvivesInvokeResolved(t *testing.T) { + idp := newMockIdP(t) + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + jwk := map[string]string{"kty": "OKP", "crv": "Ed25519", "x": b64url(public)} + canonical, _ := json.Marshal(jwk) + thumbprint := sha256.Sum256(canonical) + header, _ := json.Marshal(map[string]string{"alg": "HS256", "kid": accKID}) + claims, _ := json.Marshal(map[string]any{ + "iss": accIssuer, "aud": accAudience, "sub": "alice", + "exp": time.Now().Add(time.Minute).Unix(), + "cnf": map[string]string{"jkt": b64url(thumbprint[:])}, + }) + signing := b64url(header) + "." + b64url(claims) + mac := hmac.New(sha256.New, accSecret) + mac.Write([]byte(signing)) + token := signing + "." + b64url(mac.Sum(nil)) + ath := sha256.Sum256([]byte(token)) + proofHeader, _ := json.Marshal(map[string]any{"alg": "EdDSA", "typ": "dpop+jwt", "jwk": jwk}) + proofClaims, _ := json.Marshal(map[string]any{ + "htu": "https://gateway.test/tools", "htm": "POST", "iat": time.Now().Unix(), + "jti": "context-proof", "ath": b64url(ath[:]), + }) + proofSigning := b64url(proofHeader) + "." + b64url(proofClaims) + proof := proofSigning + "." + b64url(ed25519.Sign(private, []byte(proofSigning))) + config := strings.Replace(idpPlugin(idp.srv.URL+"/jwks", true), " algorithms: [HS256]", + " algorithms: [HS256]\n dpop: {mode: required, algorithms: [EdDSA]}", 1) + config = strings.Replace(config, "capabilities: [perform_http]", "capabilities: [perform_http, read_headers]", 1) + ` +routes: + - tool: lookup + authentication: [idp] + authorization: + pre_invocation: ["require(authenticated)"] +` + eng := accEngine(t, config, WithPrivateDestinations()) + identity := NewIdentityPayload(TokenSourceBearer, map[string]string{"authorization": "DPoP " + token, "dpop": proof}) + ext := &Extensions{Meta: &MetaExtension{EntityType: "tool", EntityName: "lookup"}, + Http: &HttpExtension{Method: "POST", Path: "/tools", Host: "gateway.test", Scheme: "https"}} + payload := MessagePayload{Message: NewMessage("user", NewToolCallPart(ToolCall{ + ToolCallID: "proof-call", Name: "lookup", Arguments: map[string]any{}, + }))} + first, ct, bg, err := eng.InvokeResolved(identity, "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil) + if err != nil { + t.Fatal(err) + } + if bg != nil { + bg.Close() + } + wantAllowed(t, "first proof", first) + resolved, err := first.DeserializeExtensions() + if err != nil { + t.Fatal(err) + } + if resolved == nil || resolved.Security == nil || resolved.Security.Subject == nil || resolved.Security.Subject.KeyBinding == nil { + t.Fatal("verified key binding was lost") + } + if binding := resolved.Security.Subject.KeyBinding; binding.Method != "dpop" || binding.Thumbprint != b64url(thumbprint[:]) { + t.Fatalf("unexpected key binding: %+v", binding) + } + second, next, bg, err := eng.InvokeResolved(identity, "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, ct) + if err != nil { + t.Fatal(err) + } + if bg != nil { + bg.Close() + } + if next != nil { + next.Close() + } + wantAllowed(t, "same request", second) + replay, last, bg, err := eng.InvokeResolved(identity, "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil) + if err != nil { + t.Fatal(err) + } + if bg != nil { + bg.Close() + } + if last != nil { + last.Close() + } + wantDenied(t, "different request", replay, "auth.dpop_replay") +} + +func TestAcceptanceQueryIdentityAndTypedHistory(t *testing.T) { + idp := newMockIdP(t) + config := strings.Replace(idpPlugin(idp.srv.URL+"/jwks", true), " claim_mapper: standard", + " claim_mapper: standard\n credential: {kind: query_param, name: access_token}", 1) + config = strings.Replace(config, "capabilities: [perform_http]", "capabilities: [perform_http, read_query]", 1) + ` +routes: + - tool: lookup + authentication: [idp] + authorization: + pre_invocation: ["require(authenticated)"] +` + eng := accEngine(t, config, WithPrivateDestinations()) + query := "access_token=" + mintJWT(t, "alice", nil, time.Minute, nil) + ext := &Extensions{ + Meta: &MetaExtension{EntityType: "tool", EntityName: "lookup"}, + Http: &HttpExtension{Query: &query, ClientAddress: "2001:0db8::1"}, + Agent: &AgentExtension{Conversation: &ConversationContext{ + History: []Message{NewMessage("user", NewTextPart("earlier turn"))}, + }}, + } + payload := MessagePayload{Message: NewMessage("assistant", NewToolCallPart(ToolCall{ + ToolCallID: "query-call", Name: "lookup", Arguments: map[string]any{}, + }))} + result, ct, bg, err := eng.InvokeResolved(NewIdentityPayload(TokenSourceBearer, nil), + "cmf.tool_pre_invoke", PayloadCMFMessage, payload, ext, nil) + if err != nil { + t.Fatal(err) + } + defer ct.Close() + defer bg.Close() + wantAllowed(t, "query credential", result) + returned, err := result.DeserializeExtensions() + if err != nil { + t.Fatal(err) + } + if returned == nil || returned.Http == nil || returned.Http.Query == nil || *returned.Http.Query != "[REDACTED]" { + t.Fatalf("query was not redacted: %+v", returned) + } + if returned.Http.ClientAddress != "2001:db8::/64" { + t.Fatalf("client address was not anonymized: %q", returned.Http.ClientAddress) + } + if returned.Agent == nil || returned.Agent.Conversation == nil || len(returned.Agent.Conversation.History) != 1 || + returned.Agent.Conversation.History[0].Role != "user" { + t.Fatal("typed history was lost through FFI") + } + _, _, _, err = eng.InvokeByName("cmf.tool_pre_invoke", PayloadCMFMessage, payload, returned, nil) + if !errors.Is(err, ErrCpexParse) { + t.Fatalf("redacted query must be refused as live input: %v", err) + } +} diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go index d9a84596..5ebbe43a 100644 --- a/go/cpex/apl_test.go +++ b/go/cpex/apl_test.go @@ -27,7 +27,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } trusted_issuers: - issuer: "https://idp.test.local" audiences: ["test-api"] diff --git a/go/cpex/engine.go b/go/cpex/engine.go index 03b5c483..06528c42 100644 --- a/go/cpex/engine.go +++ b/go/cpex/engine.go @@ -112,7 +112,8 @@ type PolicyEngine struct { handle C.CpexEngine // protected by mu; nil after Shutdown } -// ContextTable holds per-plugin context state across hook invocations. +// ContextTable holds per-plugin state across hooks in one request. +// Start each new request with nil. // Opaque handle to Rust-owned data — not serialized. type ContextTable struct { handle C.CpexContextTable diff --git a/go/cpex/engine_test.go b/go/cpex/engine_test.go index 86442276..a9d1ae0b 100644 --- a/go/cpex/engine_test.go +++ b/go/cpex/engine_test.go @@ -83,7 +83,7 @@ func TestIdentityResolveRejectsCallerSuppliedOutputs(t *testing.T) { t.Fatal(err) } for name, payload := range map[string]IdentityPayload{ - "subject": {Source: TokenSourceBearer, Subject: &SubjectExtension{ID: "forged"}}, + "subject": {Source: TokenSourceBearer, Subject: &SubjectExtension{ID: "forged"}}, "raw_claims": {Source: TokenSourceBearer, RawClaims: map[string]any{"sub": "forged"}}, } { t.Run(name, func(t *testing.T) { @@ -1041,7 +1041,7 @@ func TestExtensionsAddedFieldsRoundTrip(t *testing.T) { Turn: &turn, AgentID: "agent_1", Conversation: &ConversationContext{ - History: []any{"prior turn"}, + History: []Message{NewMessage("user", NewTextPart("prior turn"))}, Summary: "user asked for compensation lookup", Topics: []string{"hr", "compensation"}, }, diff --git a/go/cpex/types.go b/go/cpex/types.go index edd3e57d..89662521 100644 --- a/go/cpex/types.go +++ b/go/cpex/types.go @@ -70,13 +70,20 @@ type SecurityExtension struct { Data map[string]DataPolicy `msgpack:"data,omitempty"` } +// KeyBinding records a verified proof of key possession. +type KeyBinding struct { + Method string `msgpack:"method"` + Thumbprint string `msgpack:"thumbprint"` +} + // SubjectExtension represents the authenticated caller. type SubjectExtension struct { - ID string `msgpack:"id,omitempty"` - SubjectType string `msgpack:"subject_type,omitempty"` - Roles []string `msgpack:"roles,omitempty"` - Permissions []string `msgpack:"permissions,omitempty"` - Teams []string `msgpack:"teams,omitempty"` + KeyBinding *KeyBinding `msgpack:"key_binding,omitempty"` + ID string `msgpack:"id,omitempty"` + SubjectType string `msgpack:"subject_type,omitempty"` + Roles []string `msgpack:"roles,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + Teams []string `msgpack:"teams,omitempty"` // Claims holds the token claims the resolver kept, with their JSON // shape: a value can be a string, bool, number, array or nested map. Claims map[string]any `msgpack:"claims,omitempty"` @@ -93,8 +100,9 @@ const ( // ClientExtension is the OAuth client or gateway that brokered the // request, as distinct from the user in SubjectExtension. type ClientExtension struct { - ClientID string `msgpack:"client_id"` - ClientName string `msgpack:"client_name,omitempty"` + KeyBinding *KeyBinding `msgpack:"key_binding,omitempty"` + ClientID string `msgpack:"client_id"` + ClientName string `msgpack:"client_name,omitempty"` // TrustLevel is one of the ClientTrust constants or a custom string. // Empty means first_party on the Rust side. TrustLevel string `msgpack:"trust_level,omitempty"` @@ -109,8 +117,9 @@ type ClientExtension struct { // WorkloadIdentity is an attested workload identity, used for both // SecurityExtension.CallerWorkload and SecurityExtension.ThisWorkload. type WorkloadIdentity struct { - SpiffeID string `msgpack:"spiffe_id,omitempty"` - TrustDomain string `msgpack:"trust_domain,omitempty"` + KeyBinding *KeyBinding `msgpack:"key_binding,omitempty"` + SpiffeID string `msgpack:"spiffe_id,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` // AttestedAt is an RFC 3339 timestamp. AttestedAt string `msgpack:"attested_at,omitempty"` Attestor string `msgpack:"attestor,omitempty"` @@ -149,6 +158,10 @@ type RetentionPolicy struct { // identity.resolve invocation so the route's own authentication list // applies. type HttpExtension struct { + // Query excludes the leading ?. Nil means absent; an empty string means a bare ?. + // Reuse the original request value between hooks; results redact this field. + Query *string `msgpack:"query,omitempty"` + ClientAddress string `msgpack:"client_address,omitempty"` RequestHeaders map[string]string `msgpack:"request_headers,omitempty"` ResponseHeaders map[string]string `msgpack:"response_headers,omitempty"` // Status is the response status code. Zero means unset. @@ -205,9 +218,8 @@ type AgentExtension struct { // ConversationContext is per-conversation summary state, shared across // turns. Mirrors `praxis_policy_core::extensions::agent::ConversationContext`. type ConversationContext struct { - // Recent conversation history, lightweight summaries (free-form - // JSON-style values to match Rust's Vec). - History []any `msgpack:"history,omitempty"` + // Prior turns, oldest first. Free-form summaries belong in Summary. + History []Message `msgpack:"history,omitempty"` // LLM-generated conversation summary. Summary string `msgpack:"summary,omitempty"` // Detected topics for routing / classification. diff --git a/scripts/release/check-release.py b/scripts/release/check-release.py index 946527be..9e857701 100644 --- a/scripts/release/check-release.py +++ b/scripts/release/check-release.py @@ -41,7 +41,7 @@ def main(): check(package["version"] == version, f"{package['name']} has stale version {package['version']}") for dep in package["dependencies"]: if dep["name"].startswith("praxis-policy"): - check(dep["req"] == "=0.5.0", f"{package['name']}: {dep['name']} must pin PPE =0.5.0") + check(dep["req"] == "=0.6.0", f"{package['name']}: {dep['name']} must pin PPE =0.6.0") if package["name"] in PUBLISHED and dep["kind"] != "dev" and dep.get("path") is not None: check(dep["name"] in PUBLISHED, f"{package['name']} depends on unpublished {dep['name']}") check(dep["req"] == f"^{version}", f"{package['name']}: stale requirement for {dep['name']}") @@ -53,7 +53,7 @@ def main(): go_abi = re.search(r"const expectedFFIABIVersion uint32 = (\d+)", (ROOT / "go/cpex/abi.go").read_text()) check(rust_abi and go_abi and rust_abi[1] == go_abi[1], "Rust and Go FFI ABI versions differ") check(f"## [{version}] - " in (ROOT / "CHANGELOG.md").read_text(), "missing dated changelog entry") - print(f"release-check: CPEX {version}, four publishable crates, PPE 0.5.0, FFI ABI {rust_abi[1]}") + print(f"release-check: CPEX {version}, four publishable crates, PPE 0.6.0, FFI ABI {rust_abi[1]}") if __name__ == "__main__": diff --git a/tests/parity/harness/src/engine.rs b/tests/parity/harness/src/engine.rs index b5a6ac7a..1f3c921e 100644 --- a/tests/parity/harness/src/engine.rs +++ b/tests/parity/harness/src/engine.rs @@ -111,6 +111,7 @@ impl Engine { backend: &Value, ) -> MediateOutcome { let mut stages = Vec::new(); + let mut context_table = None; let mut ext = Extensions { meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), @@ -148,13 +149,19 @@ impl Engine { }; let (res, bg) = self .mgr - .invoke_named::(&hooks.identity, payload, ext.clone(), None) + .invoke_named::( + &hooks.identity, + payload, + ext.clone(), + context_table.take(), + ) .await; bg.wait_for_background_tasks().await; stages.push(project_stage(&hooks.identity, &res)); if !res.continue_processing { return halted(stages, &res); } + context_table = Some(res.context_table.clone()); if let Some(identity) = IdentityPayload::from_pipeline_result(&res) { ext = identity.apply_to_extensions(ext); } @@ -175,7 +182,7 @@ impl Engine { }; let (pre, bg) = self .mgr - .invoke_named::(&hooks.pre, pre_payload, ext.clone(), None) + .invoke_named::(&hooks.pre, pre_payload, ext.clone(), context_table.take()) .await; bg.wait_for_background_tasks().await; stages.push(project_stage(&hooks.pre, &pre)); @@ -199,7 +206,12 @@ impl Engine { }; let (post, bg) = self .mgr - .invoke_named::(&hooks.post, post_payload, post_ext, None) + .invoke_named::( + &hooks.post, + post_payload, + post_ext, + Some(pre.context_table.clone()), + ) .await; bg.wait_for_background_tasks().await; stages.push(project_stage(&hooks.post, &post)); @@ -405,7 +417,10 @@ fn project_raw_credentials( .map(|(role, t)| { json!({ "role": to_json(role), - "source_header": t.source_header, + "source_header": match &t.source { + ppe::praxis_policy_core::extensions::Credential::Header { name } => name.clone(), + _ => panic!("legacy parity requires a header credential"), + }, "kind": to_json(&t.kind), "token": if t.token.is_empty() { Value::Null } else { json!(SCRUBBED_TOKEN) }, }) diff --git a/tests/parity/hook-renames.yaml b/tests/parity/hook-renames.yaml index c3f6ef1b..4d3dfae3 100644 --- a/tests/parity/hook-renames.yaml +++ b/tests/parity/hook-renames.yaml @@ -1,5 +1,5 @@ # Reviewed hook rename table: hook names CPEX 0.2.3 accepted, mapped to the -# names PPE 0.5.0 (CPEX 0.3.0) dispatches. +# names PPE 0.6.0 (CPEX 0.3.0) dispatches. # # The parity harness applies it to every scenario's `hooks` block and every # `invoke` request's `hook` before replaying, and to the `hook` field of the @@ -41,5 +41,5 @@ unchanged: - token.delegate - elicit -# No PPE 0.5.0 builtin name is outside the table's targets and identity rows. -unused: [] +# New hooks have no counterpart in the legacy captures. +unused: [ratelimit.pre_identity, ratelimit.post_identity] diff --git a/tests/parity/intended-differences.yaml b/tests/parity/intended-differences.yaml index 6654d82a..216155d8 100644 --- a/tests/parity/intended-differences.yaml +++ b/tests/parity/intended-differences.yaml @@ -586,3 +586,17 @@ differences: proto_error_code: null reason: 'elicitation check failed: elicitation handler error elicitation.unknown_id: check: plugin `manager-approval` halted: unknown elicitation id' cause: 'PPE 0.5.0 binds CIBA approvals to requester, route and tool and atomically consumes each approved id once (praxis-proxy/policy#188). The fixture first redeems the id as evan, then retries it as harry; the second call now denies with elicitation.unknown_id instead of allowing another subject to reuse the approval.' + +- scenario: identity-jwt + request: malformed-token + field: code + before: auth.malformed_header + after: auth.malformed_credential + cause: "PPE 0.6.0 uses location-independent JWT extraction denial codes (praxis-proxy/policy#96). The malformed credential still denies." + +- scenario: identity-jwt + request: malformed-token + field: stages[0].violation.code + before: auth.malformed_header + after: auth.malformed_credential + cause: "PPE 0.6.0 uses location-independent JWT extraction denial codes (praxis-proxy/policy#96). The malformed credential still denies." diff --git a/tests/parity/scenarios/elicitation-ciba/config.ppe.yaml b/tests/parity/scenarios/elicitation-ciba/config.ppe.yaml index 46664566..4ca80e78 100644 --- a/tests/parity/scenarios/elicitation-ciba/config.ppe.yaml +++ b/tests/parity/scenarios/elicitation-ciba/config.ppe.yaml @@ -30,6 +30,7 @@ plugins: hooks: [elicit] capabilities: [perform_http] config: + store: { kind: memory } backchannel_endpoint: "{{MOCK_URL}}/ciba/auth" token_endpoint: "{{MOCK_URL}}/ciba/token" client_id: cpex-gateway diff --git a/tests/parity/scenarios/identity-jwt/config.ppe.yaml b/tests/parity/scenarios/identity-jwt/config.ppe.yaml index 5c2abd88..106fa6eb 100644 --- a/tests/parity/scenarios/identity-jwt/config.ppe.yaml +++ b/tests/parity/scenarios/identity-jwt/config.ppe.yaml @@ -15,7 +15,7 @@ plugins: capabilities: [perform_http] config: role: user - header: Authorization + credential: { kind: header, name: Authorization } claim_mapper: standard trusted_issuers: - issuer: https://idp.parity.test