From ba5070760411ac7d6a9a138f83eb1011b938a467 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 1 Oct 2026 16:10:36 -0400 Subject: [PATCH 1/2] build(deps): consolidate open Dependabot updates Signed-off-by: Frederico Araujo --- .github/workflows/ci.yml | 23 ++++-- .github/workflows/coverage.yaml | 3 +- .github/workflows/docs-deploy.yaml | 2 +- .github/workflows/release-ffi.yaml | 3 +- .github/workflows/release.yaml | 12 ++- .github/workflows/supply-chain.yaml | 4 +- .github/workflows/tutorial.yaml | 4 +- CHANGELOG.md | 4 + Cargo.lock | 76 ++++++++++++++++--- builtins/pdps/cel/Cargo.toml | 2 +- builtins/pdps/cel/src/resolver.rs | 11 +-- builtins/plugins/identity-jwt/Cargo.toml | 8 +- .../identity-jwt/tests/jwks_url_e2e.rs | 13 ++-- .../plugins/identity-jwt/tests/jwt_e2e.rs | 3 +- builtins/session/valkey/Cargo.toml | 2 +- crates/apl-cpex/Cargo.toml | 2 +- crates/cpex-hosts-python/Cargo.toml | 2 +- crates/cpex-hosts-python/src/venv.rs | 8 +- docs/content/docs/apl/pdp.md | 2 + 19 files changed, 135 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc9bb50..b047effb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,9 @@ jobs: with: fetch-depth: 1 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable with: + toolchain: "1.96.0" components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - name: rustfmt @@ -70,7 +71,9 @@ jobs: with: fetch-depth: 1 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - uses: Swatinem/rust-cache@v2 - name: cargo test # Valkey/testcontainers integration tests are `#[ignore]`d by default @@ -112,11 +115,13 @@ jobs: path: cpex-python fetch-depth: 1 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - uses: Swatinem/rust-cache@v2 - name: make test-python-e2e env: @@ -133,9 +138,11 @@ jobs: with: fetch-depth: 1 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version: "1.25.4" - uses: Swatinem/rust-cache@v2 @@ -154,7 +161,9 @@ jobs: with: fetch-depth: 1 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" # NOTE: intentionally NO Swatinem/rust-cache here, AND we wipe the registry # index before packaging. The committed Cargo.lock is byte-identical to a # fresh x86_64 resolution (verified in an amd64 container), so the lock is diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 5232f582..2c969fa2 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -31,8 +31,9 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable with: + toolchain: "1.96.0" components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 - name: Install cargo-llvm-cov diff --git a/.github/workflows/docs-deploy.yaml b/.github/workflows/docs-deploy.yaml index b56cc032..567b8af9 100644 --- a/.github/workflows/docs-deploy.yaml +++ b/.github/workflows/docs-deploy.yaml @@ -62,4 +62,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml index ebcb50d6..296ef619 100644 --- a/.github/workflows/release-ffi.yaml +++ b/.github/workflows/release-ffi.yaml @@ -69,8 +69,9 @@ jobs: # so the cross (musl) target ends up missing on the toolchain actually # used (E0463: can't find crate for `core`). Pinning installs the # target onto 1.96.0 directly. Mirrors the other workflows. - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable with: + toolchain: "1.96.0" targets: ${{ matrix.target }} - name: Cache cargo build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a946e39f..3bea705d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -35,7 +35,9 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - name: Check tag == workspace version if: github.event_name == 'push' env: @@ -58,7 +60,9 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace @@ -69,7 +73,9 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" # NOTE: intentionally NO Swatinem/rust-cache here. A restored partial # `~/.cargo/registry` index makes `cargo package/publish --locked` abort # with "cannot update the lock file" against the sparse index even when diff --git a/.github/workflows/supply-chain.yaml b/.github/workflows/supply-chain.yaml index 2afa071c..629301e5 100644 --- a/.github/workflows/supply-chain.yaml +++ b/.github/workflows/supply-chain.yaml @@ -35,7 +35,9 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - uses: Swatinem/rust-cache@v2 - name: Install cargo-deny run: cargo install cargo-deny --locked diff --git a/.github/workflows/tutorial.yaml b/.github/workflows/tutorial.yaml index 79420bd7..cd7127d0 100644 --- a/.github/workflows/tutorial.yaml +++ b/.github/workflows/tutorial.yaml @@ -32,7 +32,9 @@ jobs: with: fetch-depth: 1 - name: Install Rust 1.96.0 - uses: dtolnay/rust-toolchain@1.96.0 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.0" - uses: Swatinem/rust-cache@v2 # `make tutorial-check` brings the Keycloak compose stack up, runs the # no-IdP and IdP-backed module checks, then tears the stack down. diff --git a/CHANGELOG.md b/CHANGELOG.md index bee9ddcb..4e2b8bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **Out-of-process host for existing Python CPEX plugins.** A new `cpex-hosts-python` crate registers `kind: isolated_venv`, running an unmodified Python CPEX plugin in its own cached virtualenv as a subprocess instead of in-process through the PyO3 bindings. Each plugin gets a venv keyed by a SHA-256 fingerprint of its requirements + manifest (rebuilt when either changes, `rmtree`d rather than upgraded in place so a removed dependency actually disappears), and the host drives the Python framework's `worker.py` over a newline-delimited JSON stdio protocol. Hook payloads, `context`, and the capability-filtered `Extensions` view cross as JSON; returns come back as a serialized `PluginResult`, with `modified_extensions` merged through the executor's existing copy-on-write tier validation — the host implements no tier logic of its own. Failure modes the executor cannot otherwise distinguish (venv build failure, worker death mid-flight, a task over `max_content_size`, per-invocation timeout) map to distinct `PluginError`s carrying a stable `code` and structured `details`, so the executor's configured `on_error` policy applies unchanged. Pure Rust plus a subprocess — no libpython link, so the crate is in `default-members` and a plain `cargo build` does not require a Python dev install. The wire contract is pinned in `docs/specs/extensions-wire-contract.md`; CMF §3 remains normative for the extension slots themselves. (#149) +### Changed + +- **CEL policies now use `cel` 0.14.** List membership uses `in`; the upstream interpreter no longer provides container `.contains()` or the default `min`/`max` functions. Host-registered functions should use names distinct from CEL built-ins such as `double`, because built-in overloads can take precedence. (#102) + ### Fixed - **A failed `python3 -m venv` or `pip install` now reports its exit code and stderr as structured `details`.** Both paths returned a `VenvBuild` error whose entire content was a prose string, so an operator's log pipeline had to string-match English to recover pip's own diagnosis. They now build `HostError::VenvCommand`, which carries the failing `step`, the exit code (`null` when the child was killed by a signal), and a bounded stderr excerpt into `PluginError::Execution.details`. The stable `code` is unchanged (`venv_build_failed`), and the pip *timeout* path is deliberately still message-only — nothing exited, so there is no exit code to report. (#149) diff --git a/Cargo.lock b/Cargo.lock index 3f4ff669..f3c218a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,7 +87,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.20", "tokio", "tracing", @@ -420,6 +420,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.19.4" @@ -622,9 +631,9 @@ dependencies = [ [[package]] name = "cel" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814" dependencies = [ "antlr4rust", "chrono", @@ -745,6 +754,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -850,7 +865,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "tokio", "tracing", "uuid", @@ -986,7 +1001,6 @@ dependencies = [ "futures", "jsonwebtoken", "mockito", - "rand 0.8.6", "reqwest", "rsa", "serde", @@ -1039,7 +1053,7 @@ dependencies = [ "redis", "serde", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "testcontainers", "testcontainers-modules", "thiserror 2.0.20", @@ -1108,6 +1122,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1210,7 +1233,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "const-oid", + "const-oid 0.9.6", ] [[package]] @@ -1219,7 +1242,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1249,11 +1272,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1809,6 +1843,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -3356,7 +3399,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -3721,6 +3764,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" diff --git a/builtins/pdps/cel/Cargo.toml b/builtins/pdps/cel/Cargo.toml index ae05c4d3..bc0e3656 100644 --- a/builtins/pdps/cel/Cargo.toml +++ b/builtins/pdps/cel/Cargo.toml @@ -43,7 +43,7 @@ apl-core = { workspace = true } # credential is fresh enough"). # `json` and `bytes` are deliberately off — APL marshals JSON at its # own layer, and CEL bytes ops aren't needed for ABAC predicates. -cel = { version = "0.13", default-features = false, features = ["regex", "chrono"] } +cel = { version = "0.14", default-features = false, features = ["regex", "chrono"] } async-trait = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } diff --git a/builtins/pdps/cel/src/resolver.rs b/builtins/pdps/cel/src/resolver.rs index fdb5c2e6..816cd2bb 100644 --- a/builtins/pdps/cel/src/resolver.rs +++ b/builtins/pdps/cel/src/resolver.rs @@ -168,9 +168,10 @@ impl CelResolver { /// }); /// ``` /// - /// Function names that collide with the CEL standard library - /// (`size`, `has`, `matches`, etc.) silently shadow the built-in - /// — be deliberate. + /// Use function names that do not collide with the CEL standard + /// library (`int`, `double`, `size`, `matches`, etc.). CEL 0.14's + /// overload dispatch can select a built-in over a host function + /// with the same name. /// /// # Ownership of the function set /// @@ -572,7 +573,7 @@ mod tests { async fn custom_function_registration_round_trips() { let r = CelResolver::new() .with_functions(|ctx| { - ctx.add_function("double", |n: i64| -> i64 { n * 2 }); + ctx.add_function("host_twice", |n: i64| -> i64 { n * 2 }); }) .with_functions(|ctx| { ctx.add_function("shout", |s: Arc| -> String { s.to_uppercase() }); @@ -581,7 +582,7 @@ mod tests { // First registered function works. let out = r - .evaluate(&cel_call("double(21) == 42"), &bag) + .evaluate(&cel_call("host_twice(21) == 42"), &bag) .await .unwrap(); assert_eq!( diff --git a/builtins/plugins/identity-jwt/Cargo.toml b/builtins/plugins/identity-jwt/Cargo.toml index 7fd1a54b..02ca260a 100644 --- a/builtins/plugins/identity-jwt/Cargo.toml +++ b/builtins/plugins/identity-jwt/Cargo.toml @@ -85,12 +85,8 @@ mockito = "1" # RSA keypair + JWK fixtures for tests. `rsa` generates keypairs; # `pkcs8` encodes them in the PEM format jsonwebtoken accepts. rsa = { version = "0.9", features = ["pem"] } -# rsa 0.9's `RsaPrivateKey::new(&mut rng, bits)` takes an rng -# implementing `rand_core::CryptoRngCore` — `rand::thread_rng()` -# satisfies that. Pinned to 0.8: rsa 0.9 (latest stable) depends on -# rand_core 0.6, which rand 0.9 (rand_core 0.9) no longer implements, -# so the two are incompatible until rsa 0.10 ships. Test-only dep. -rand = "0.8" +# Use rsa's own rand_core re-export for key generation in tests. This +# avoids a direct rand dependency and keeps the RNG on rsa's required API. [lints] workspace = true diff --git a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs index bbd52a7d..46e2d95e 100644 --- a/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwks_url_e2e.rs @@ -34,6 +34,7 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use mockito::Server; use rsa::pkcs1::EncodeRsaPublicKey; use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::rand_core::OsRng; use rsa::traits::PublicKeyParts; use rsa::{RsaPrivateKey, RsaPublicKey}; use serde_json::{json, Value}; @@ -110,7 +111,7 @@ fn resolver_config(jwks_url: &str) -> PluginConfig { #[tokio::test(flavor = "multi_thread")] async fn initialize_fetches_jwks_and_validates_token() { // 1. Generate a keypair and serve its public key as a JWKS. - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); let pub_key = RsaPublicKey::from(&priv_key); let priv_pem = priv_key @@ -198,7 +199,7 @@ async fn initialize_fetches_jwks_and_validates_token() { /// `PluginManager::initialize()`. #[tokio::test(flavor = "multi_thread")] async fn skipping_initialize_rejects_with_untrusted_issuer() { - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); let pub_key = RsaPublicKey::from(&priv_key); let priv_pem = priv_key @@ -309,7 +310,7 @@ fn mint_jwt_with_kid(private_pem: &str, kid: &str, claims: Value) -> String { /// first key (A) and reject the valid token as signature_invalid. #[tokio::test(flavor = "multi_thread")] async fn kid_selects_correct_key_when_jwks_has_multiple() { - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); let pub_a = RsaPublicKey::from(&priv_a); @@ -376,7 +377,7 @@ async fn kid_selects_correct_key_when_jwks_has_multiple() { /// from forgery at the audit layer. #[tokio::test(flavor = "multi_thread")] async fn unknown_kid_yields_unknown_kid_violation() { - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); let pub_key = RsaPublicKey::from(&priv_key); let priv_pem = priv_key @@ -519,7 +520,7 @@ async fn jwks_fetch_times_out_when_endpoint_stalls() { /// empty key. #[tokio::test(flavor = "multi_thread")] async fn jwks_unreachable_at_initialize_soft_fails() { - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); let priv_pem = priv_key .to_pkcs8_pem(LineEnding::LF) @@ -592,7 +593,7 @@ async fn jwks_refresh_picks_up_rotated_key() { use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); let pub_a = RsaPublicKey::from(&priv_a); diff --git a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs index c2f98949..2e67c40a 100644 --- a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs @@ -31,6 +31,7 @@ use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; use cpex_plugin_identity_jwt::JwtIdentityResolver; use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use rsa::rand_core::OsRng; use rsa::{RsaPrivateKey, RsaPublicKey}; use serde_json::{json, Value}; @@ -52,7 +53,7 @@ struct Keypair { fn keypair() -> &'static Keypair { static KP: OnceLock = OnceLock::new(); KP.get_or_init(|| { - let mut rng = rand::thread_rng(); + let mut rng = OsRng; let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); let pub_key = RsaPublicKey::from(&priv_key); Keypair { diff --git a/builtins/session/valkey/Cargo.toml b/builtins/session/valkey/Cargo.toml index bfb971c1..6e4db817 100644 --- a/builtins/session/valkey/Cargo.toml +++ b/builtins/session/valkey/Cargo.toml @@ -37,7 +37,7 @@ serde_yaml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } -sha2 = "0.10" +sha2 = "0.11" # URL building/parsing so connection credentials are percent-encoded and # the wire scheme is consistent with the TLS setting (already in the tree # via redis-rs). diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml index 65a0838d..e8998ce8 100644 --- a/crates/apl-cpex/Cargo.toml +++ b/crates/apl-cpex/Cargo.toml @@ -44,7 +44,7 @@ tracing = { workspace = true } # is explicitly documented as not-stable-across-Rust-versions; session # keys persist across process restarts (SessionStore), so we need an # algorithmically fixed hash. -sha2 = "0.10" +sha2 = "0.11" [dev-dependencies] serde = { workspace = true } diff --git a/crates/cpex-hosts-python/Cargo.toml b/crates/cpex-hosts-python/Cargo.toml index 08c39230..2ece047c 100644 --- a/crates/cpex-hosts-python/Cargo.toml +++ b/crates/cpex-hosts-python/Cargo.toml @@ -47,7 +47,7 @@ uuid = { workspace = true } # field would wipe one of those copies and leave the rest, which reads as a # control that is not one. The real boundary is the worker process, and it is # documented as such in `credentials`' "Residual exposure" note. -sha2 = "0.10" +sha2 = "0.11" # `process` and `io-util` are absent from the workspace tokio floor (the # embedded library never spawns children). This host's whole job is a diff --git a/crates/cpex-hosts-python/src/venv.rs b/crates/cpex-hosts-python/src/venv.rs index d0d78ccb..8d1cdbff 100644 --- a/crates/cpex-hosts-python/src/venv.rs +++ b/crates/cpex-hosts-python/src/venv.rs @@ -58,6 +58,7 @@ // keeps the intended sharing while removing the mutual-destruction window. use std::collections::BTreeSet; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -152,7 +153,12 @@ pub fn hash_file_or_empty(path: Option<&Path>) -> String { }, None => hasher.update(b""), } - format!("{:x}", hasher.finalize()) + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(hex, "{byte:02x}"); + } + hex } /// Filesystem-safe stem for a fully-qualified class name. diff --git a/docs/content/docs/apl/pdp.md b/docs/content/docs/apl/pdp.md index 67356b86..206118ad 100644 --- a/docs/content/docs/apl/pdp.md +++ b/docs/content/docs/apl/pdp.md @@ -71,6 +71,8 @@ authorization: - cel: { expr: "subject.department == 'compliance' || 'admin' in subject.roles" } ``` +With CEL 0.14, use `in` for list membership; container `.contains()` and the default `min`/`max` functions are unavailable. If the host registers custom CEL functions, give them names distinct from built-ins such as `double`, whose overloads may take precedence. + ## How it connects to the pipeline A PDP resolver is registered with the manager like any other capability. When the evaluator hits a PDP effect, it dispatches to the resolver for that dialect, passing the attribute bag and the call's arguments, and routes the `Allow` / `Deny` decision through `on_allow` / `on_deny`. The decision and its diagnostics are recorded in the audit log. See [Effects]({{< relref "/docs/apl/effects" >}}) for how PDP reactions sequence with the rest of a policy. From 13221dc024c9568eb2dc15c415f718c1ae09ed29 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 1 Oct 2026 20:26:22 -0400 Subject: [PATCH 2/2] fix(ci): update audit lockfile and Python e2e setup Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 + Cargo.lock | 12 +++--- crates/cpex-hosts-python/README.md | 9 ++--- crates/cpex-hosts-python/src/testing.rs | 54 ++++++++++++------------- 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2b8bf8..c2375110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Security +- **Updated the locked TLS dependency to `rustls` 0.23.45**, fixing RUSTSEC-2026-0285, and replaced the yanked `chacha20` 0.10.1 with 0.10.2. + - **The host now verifies the worker understands what it is about to be sent, and fails closed at startup when it does not.** An older `worker.py` does not reject a wire field it predates — it ignores it, so a plugin declaring `read_inbound_credentials` against one started cleanly and then ran with **no credential at all**, silently, which is precisely what fail-closed rule 2 exists to prevent. Version strings cannot distinguish the two cases: two framework builds shipped as `0.1.2` with different worker protocols. `initialize()` therefore probes the worker with a new `capabilities` task before any plugin code loads, and the worker answers with the feature names it actually implements. A worker predating the handshake replies `task type not supported.`, which the host reads as "no features" — the conservative answer — while a timeout or a dead worker stays an error, since those mean the probe never completed. Enforcement is per plugin rather than a blanket version gate: a credential capability requires the `credential` feature, an extensions capability requires `extensions`, and a plugin declaring nothing gated still starts on any worker. A mismatch fails that plugin at startup with an error naming the missing feature and what the worker did report, rather than deferring the discovery to a live request; the respawn path re-verifies, because the replacement resolves `worker.py` from a venv that may have been rebuilt since. (#149) - **Raw credential material can now reach an out-of-process worker.** `RawInboundToken.token` and `RawDelegatedToken.token` are `#[serde(skip)]`, and the "raw credentials never leave the host process" invariant has held because nothing read those fields directly. The `isolated_venv` host narrowly reverses that for two hooks — `identity_resolve` and `token_delegate`, the only ones whose Python payload models a raw token at all — by reading the in-memory field and sending the plaintext in a dedicated `credential` DTO. This is opt-in twice over and fails closed: a plugin receives nothing unless it declares `read_inbound_credentials` or `read_delegated_tokens`, and a plugin that declares one but cannot be served (no extension, empty token) causes the dispatch to error rather than silently sending no credential — an empty bearer would otherwise read as "no authentication required". The `raw_credentials` extension slot itself is never carried, so no hollow token slot invites a plugin to misread "not on this channel" as "no credential present", and the sensitive-header strip (`Authorization` / `Cookie` / `Set-Cookie` / `X-API-Key`, case-insensitive, both directions) keeps credential headers out of the `http` slot regardless. The FFI, Python-bindings, and audit paths are untouched, and the in-process hosts are unaffected. diff --git a/Cargo.lock b/Cargo.lock index f3c218a2..3b7aa581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,9 +659,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -3449,9 +3449,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -3495,9 +3495,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/crates/cpex-hosts-python/README.md b/crates/cpex-hosts-python/README.md index 8c289566..9bb592c9 100644 --- a/crates/cpex-hosts-python/README.md +++ b/crates/cpex-hosts-python/README.md @@ -229,11 +229,10 @@ CPEX_PYTHON_SOURCE=/path/to/cpex-python \ Two environmental caveats, both upstream of this crate: -- The Python framework's declared dependencies currently have no satisfiable - resolution — `pyproject.toml` requires `mcp>=1.26`, but the framework imports - `McpError`, which mcp renamed to `MCPError` in 1.26. The e2e tests therefore - pre-build the venv in two pip passes (install as declared, then downgrade - `mcp`) and let the host reuse it via its own cache. +- Older Python framework checkouts import `McpError` while declaring + `mcp>=1.26`, which renamed that symbol to `MCPError`. The e2e tests downgrade + `mcp` in their pre-built venv only for those older checkouts. Current checkouts + use the declared version, and the host reuses the cached venv in both cases. - The Python hook registry registers no `cmf.*` hooks, so a CMF round-trip cannot be proven end to end yet. The host's CMF routing is covered by unit tests, and the e2e suite asserts that a CMF hook is rejected cleanly rather diff --git a/crates/cpex-hosts-python/src/testing.rs b/crates/cpex-hosts-python/src/testing.rs index 04850356..19ae3fd7 100644 --- a/crates/cpex-hosts-python/src/testing.rs +++ b/crates/cpex-hosts-python/src/testing.rs @@ -219,22 +219,11 @@ pub fn scaffold_plugin( /// /// # Why the test builds the venv instead of letting the host do it /// -/// The Python framework's declared dependencies currently have no satisfiable -/// resolution: `pyproject.toml` requires `mcp>=1.26`, but -/// `cpex.framework.__init__` imports its MCP client, which does -/// `from mcp import McpError` — a symbol mcp renamed to `MCPError` in 1.26. A -/// clean install therefore pulls an mcp whose API the framework cannot import, -/// and the worker dies with an `ImportError` before reading a task. Adding -/// `mcp<1.26` to the requirements file instead makes pip fail outright with -/// `ResolutionImpossible`, because that contradicts the framework's own floor. -/// -/// The only combination that runs is "install as declared, then downgrade mcp" -/// — two sequential pip passes. The venv manager issues one `pip install -r`, -/// which is correct for a working package; contorting production code around a -/// contradictory upstream manifest would be the wrong fix, and the Python -/// framework is out of scope here. So the test arranges the venv and the host -/// then finds it cached, which exercises a real host path -/// (`CacheVerdict::Valid` → reuse) rather than bypassing one. +/// Older Python framework checkouts import `McpError` while declaring +/// `mcp>=1.26`, which renamed it to `MCPError`. For those checkouts only, the +/// test installs the framework as declared and then downgrades `mcp`. Newer +/// checkouts import `MCPError` and must keep the declared version. The host +/// then finds the pre-built venv cached, exercising its real reuse path. /// /// Returns `Err` with a printable reason when the venv cannot be built. pub fn prebuild_venv( @@ -287,21 +276,28 @@ pub fn prebuild_venv( source.as_os_str(), ], )?; - // Pass 2: downgrade mcp to a version whose API the framework can import. - // pip warns about the deliberate conflict; that warning is expected. - run( - &python, - &[ - "-m".as_ref(), - "pip".as_ref(), - "install".as_ref(), - "-q".as_ref(), - "mcp<1.26".as_ref(), - ], - )?; + // Legacy sources import McpError; current 0.1.x imports MCPError and + // fails if we downgrade its correctly resolved mcp package. + let mcp_client = source.join("cpex/framework/external/mcp/client.py"); + let mcp_client_source = std::fs::read_to_string(&mcp_client) + .map_err(|e| format!("could not inspect {}: {e}", mcp_client.display()))?; + if mcp_client_source.contains("McpError") { + // pip warns about the deliberate conflict with the old checkout's + // declared floor; that warning is expected in this test venv. + run( + &python, + &[ + "-m".as_ref(), + "pip".as_ref(), + "install".as_ref(), + "-q".as_ref(), + "mcp<1.26".as_ref(), + ], + )?; + } // Metadata matching what the host computes, so its cache check says Valid - // and `initialize()` skips the (unsatisfiable) reinstall. + // and `initialize()` skips reinstalling the prepared environment. let requirements = plugin_dir.join(package).join("requirements.txt"); let metadata = serde_json::json!({ "venv_path": layout.venv_path.display().to_string(),