Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
3 changes: 2 additions & 1 deletion .github/workflows/release-ffi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/supply-chain.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/tutorial.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ 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)

### 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.
Expand Down
88 changes: 71 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion builtins/pdps/cel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading